MINOR: App changes for collate support (#21068)

* feat: prep for collate support

- Added support for delayed background jobs. This is to delete the support user after a while.
- added ScheduleType.OnlyManual for applications to support only manual triggers
- Added a four hour token expiration
- Allow a null trigger type in job data (not sure what it's for)

* added test for delayed job

* added type checking for enumCleanupArgs in navbar

* - added a support toten type
- added job handler for delete token

* fixed whitelist links

* patch OnlyManual apps

* Update openmetadata-spec/src/main/resources/json/schema/entity/applications/app.json

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* added indexes for runAt

* use NO_JOB_SLEEP_SECONDS in background job test

* Fixed NavBar test

* removed backticls

* fix indexes

* change for support app

* fix tests

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Karan Hotchandani <33024356+karanh37@users.noreply.github.com>
Co-authored-by: karanh37 <karanh37@gmail.com>
Co-authored-by: Pere Miquel Brull <peremiquelbrull@gmail.com>
This commit is contained in:
Imri Paran 2025-05-26 12:02:14 +02:00 committed by GitHub
parent 6ea630d7ef
commit baee931b85
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
31 changed files with 356 additions and 93 deletions

View File

@ -0,0 +1,4 @@
ALTER TABLE background_jobs
ADD COLUMN runAt BIGINT;
CREATE INDEX background_jobs_run_at_index ON background_jobs(runAt);

View File

@ -0,0 +1,4 @@
ALTER TABLE background_jobs
ADD COLUMN runAt BIGINT;
CREATE INDEX background_jobs_run_at_index ON background_jobs(runAt);

View File

@ -60,6 +60,7 @@ import org.glassfish.jersey.media.multipart.MultiPartFeature;
import org.glassfish.jersey.server.ServerProperties; import org.glassfish.jersey.server.ServerProperties;
import org.jdbi.v3.core.Jdbi; import org.jdbi.v3.core.Jdbi;
import org.jdbi.v3.sqlobject.SqlObjects; import org.jdbi.v3.sqlobject.SqlObjects;
import org.jetbrains.annotations.NotNull;
import org.openmetadata.schema.api.security.AuthenticationConfiguration; import org.openmetadata.schema.api.security.AuthenticationConfiguration;
import org.openmetadata.schema.api.security.AuthorizerConfiguration; import org.openmetadata.schema.api.security.AuthorizerConfiguration;
import org.openmetadata.schema.api.security.ClientType; import org.openmetadata.schema.api.security.ClientType;
@ -241,8 +242,7 @@ public class OpenMetadataApplication extends Application<OpenMetadataApplication
registerEventFilter(catalogConfig, environment); registerEventFilter(catalogConfig, environment);
environment.lifecycle().manage(new ManagedShutdown()); environment.lifecycle().manage(new ManagedShutdown());
JobHandlerRegistry registry = new JobHandlerRegistry(); JobHandlerRegistry registry = getJobHandlerRegistry();
registry.register("EnumCleanupHandler", new EnumCleanupHandler(getDao(jdbi)));
environment environment
.lifecycle() .lifecycle()
.manage(new GenericBackgroundWorker(jdbi.onDemand(JobDAO.class), registry)); .manage(new GenericBackgroundWorker(jdbi.onDemand(JobDAO.class), registry));
@ -284,6 +284,12 @@ public class OpenMetadataApplication extends Application<OpenMetadataApplication
} }
} }
protected @NotNull JobHandlerRegistry getJobHandlerRegistry() {
JobHandlerRegistry registry = new JobHandlerRegistry();
registry.register("EnumCleanupHandler", new EnumCleanupHandler(getDao(jdbi)));
return registry;
}
private void registerHealthCheckJobs(OpenMetadataApplicationConfig catalogConfig) { private void registerHealthCheckJobs(OpenMetadataApplicationConfig catalogConfig) {
ServicesStatusJobHandler healthCheckStatusHandler = ServicesStatusJobHandler healthCheckStatusHandler =
ServicesStatusJobHandler.create( ServicesStatusJobHandler.create(

View File

@ -8,6 +8,7 @@ import static org.openmetadata.service.resources.apps.AppResource.SCHEDULED_TYPE
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Set;
import lombok.Getter; import lombok.Getter;
import lombok.SneakyThrows; import lombok.SneakyThrows;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
@ -66,10 +67,12 @@ public class AbstractNativeApplication implements NativeApplication {
@Override @Override
public void install(String installedBy) { public void install(String installedBy) {
// If the app does not have any Schedule Return without scheduling // If the app does not have any Schedule Return without scheduling
if (Boolean.TRUE.equals(app.getDeleted()) || (app.getAppSchedule() == null)) { if (Boolean.TRUE.equals(app.getDeleted())
return; || (app.getAppSchedule() == null)
} || Set.of(ScheduleType.NoSchedule, ScheduleType.OnlyManual)
if (app.getAppType().equals(AppType.Internal) .contains(app.getScheduleType())) {
LOG.debug("App {} does not support scheduling.", app.getName());
} else if (app.getAppType().equals(AppType.Internal)
&& (SCHEDULED_TYPES.contains(app.getScheduleType()))) { && (SCHEDULED_TYPES.contains(app.getScheduleType()))) {
try { try {
ApplicationHandler.getInstance().removeOldJobs(app); ApplicationHandler.getInstance().removeOldJobs(app);
@ -101,7 +104,8 @@ public class AbstractNativeApplication implements NativeApplication {
@Override @Override
public void triggerOnDemand(Map<String, Object> config) { public void triggerOnDemand(Map<String, Object> config) {
// Validate Native Application // Validate Native Application
if (app.getScheduleType().equals(ScheduleType.ScheduledOrManual)) { if (Set.of(ScheduleType.ScheduledOrManual, ScheduleType.OnlyManual)
.contains(app.getScheduleType())) {
AppRuntime runtime = getAppRuntime(app); AppRuntime runtime = getAppRuntime(app);
validateServerExecutableApp(runtime); validateServerExecutableApp(runtime);
// Trigger the application with the provided configuration payload // Trigger the application with the provided configuration payload

View File

@ -11,6 +11,7 @@ import com.cronutils.parser.CronParser;
import java.util.HashMap; import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Optional;
import java.util.Properties; import java.util.Properties;
import java.util.concurrent.Executors; import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ScheduledExecutorService;
@ -206,7 +207,11 @@ public class AppScheduler {
private JobDetail jobBuilder(App app, String jobIdentity) throws ClassNotFoundException { private JobDetail jobBuilder(App app, String jobIdentity) throws ClassNotFoundException {
JobDataMap dataMap = new JobDataMap(); JobDataMap dataMap = new JobDataMap();
dataMap.put(APP_NAME, app.getName()); dataMap.put(APP_NAME, app.getName());
dataMap.put("triggerType", app.getAppSchedule().getScheduleTimeline().value()); dataMap.put(
"triggerType",
Optional.ofNullable(app.getAppSchedule())
.map(v -> v.getScheduleTimeline().value())
.orElse(null));
Class<? extends NativeApplication> clz = Class<? extends NativeApplication> clz =
(Class<? extends NativeApplication>) Class.forName(app.getClassName()); (Class<? extends NativeApplication>) Class.forName(app.getClassName());
JobBuilder jobBuilder = JobBuilder jobBuilder =

View File

@ -70,6 +70,7 @@ import org.openmetadata.schema.auth.PasswordResetToken;
import org.openmetadata.schema.auth.PersonalAccessToken; import org.openmetadata.schema.auth.PersonalAccessToken;
import org.openmetadata.schema.auth.RefreshToken; import org.openmetadata.schema.auth.RefreshToken;
import org.openmetadata.schema.auth.TokenType; import org.openmetadata.schema.auth.TokenType;
import org.openmetadata.schema.auth.collate.SupportToken;
import org.openmetadata.schema.configuration.AssetCertificationSettings; import org.openmetadata.schema.configuration.AssetCertificationSettings;
import org.openmetadata.schema.configuration.WorkflowSettings; import org.openmetadata.schema.configuration.WorkflowSettings;
import org.openmetadata.schema.dataInsight.DataInsightChart; import org.openmetadata.schema.dataInsight.DataInsightChart;
@ -5764,6 +5765,7 @@ public interface CollectionDAO {
case PASSWORD_RESET -> JsonUtils.readValue(json, PasswordResetToken.class); case PASSWORD_RESET -> JsonUtils.readValue(json, PasswordResetToken.class);
case REFRESH_TOKEN -> JsonUtils.readValue(json, RefreshToken.class); case REFRESH_TOKEN -> JsonUtils.readValue(json, RefreshToken.class);
case PERSONAL_ACCESS_TOKEN -> JsonUtils.readValue(json, PersonalAccessToken.class); case PERSONAL_ACCESS_TOKEN -> JsonUtils.readValue(json, PersonalAccessToken.class);
case SUPPORT_TOKEN -> JsonUtils.readValue(json, SupportToken.class);
}; };
} }
} }

View File

@ -15,7 +15,7 @@ public class GenericBackgroundWorker implements Managed {
private static final int INITIAL_BACKOFF_SECONDS = 1; private static final int INITIAL_BACKOFF_SECONDS = 1;
private static final int MAX_BACKOFF_SECONDS = 600; // 10 minutes private static final int MAX_BACKOFF_SECONDS = 600; // 10 minutes
private static final int NO_JOB_SLEEP_SECONDS = 10; // Sleep if no jobs are available public static final int NO_JOB_SLEEP_SECONDS = 10; // Sleep if no jobs are available
private final JobDAO jobDao; private final JobDAO jobDao;
private final JobHandlerRegistry handlerRegistry; private final JobHandlerRegistry handlerRegistry;

View File

@ -26,37 +26,50 @@ public interface JobDAO {
default long insertJob( default long insertJob(
BackgroundJob.JobType jobType, JobHandler handler, String jobArgs, String createdBy) { BackgroundJob.JobType jobType, JobHandler handler, String jobArgs, String createdBy) {
return insertJob(jobType, handler, jobArgs, createdBy, null);
}
default long insertJob(
BackgroundJob.JobType jobType,
JobHandler handler,
String jobArgs,
String createdBy,
Long runAt) {
try { try {
JsonUtils.readTree(jobArgs); JsonUtils.readTree(jobArgs);
} catch (Exception e) { } catch (Exception e) {
throw new IllegalArgumentException("jobArgs must be a valid JSON string"); throw new IllegalArgumentException("jobArgs must be a valid JSON string");
} }
return insertJobInternal( return insertJobInternal(
jobType.name(), handler.getClass().getSimpleName(), jobArgs, createdBy); jobType.name(), handler.getClass().getSimpleName(), jobArgs, createdBy, runAt);
} }
@ConnectionAwareSqlUpdate( @ConnectionAwareSqlUpdate(
value = value =
"INSERT INTO background_jobs (jobType, methodName, jobArgs, createdBy) " "INSERT INTO background_jobs (jobType, methodName, jobArgs, createdBy, runAt) "
+ "VALUES (:jobType, :methodName, :jobArgs, :createdBy)", + "VALUES (:jobType, :methodName, :jobArgs, :createdBy, :runAt)",
connectionType = MYSQL) connectionType = MYSQL)
@ConnectionAwareSqlUpdate( @ConnectionAwareSqlUpdate(
value = value =
"INSERT INTO background_jobs (jobType, methodName, jobArgs,createdBy) VALUES (:jobType, :methodName, :jobArgs::jsonb,:createdBy) ", "INSERT INTO background_jobs (jobType, methodName, jobArgs,createdBy,runAt) VALUES (:jobType, :methodName, :jobArgs::jsonb,:createdBy,:runAt) ",
connectionType = POSTGRES) connectionType = POSTGRES)
@GetGeneratedKeys @GetGeneratedKeys
long insertJobInternal( long insertJobInternal(
@Bind("jobType") String jobType, @Bind("jobType") String jobType,
@Bind("methodName") String methodName, @Bind("methodName") String methodName,
@Bind("jobArgs") String jobArgs, @Bind("jobArgs") String jobArgs,
@Bind("createdBy") String createdBy); @Bind("createdBy") String createdBy,
@Bind("runAt") Long runAt);
default Optional<BackgroundJob> fetchPendingJob() throws BackgroundJobException { default Optional<BackgroundJob> fetchPendingJob() throws BackgroundJobException {
return Optional.ofNullable(fetchPendingJobInternal()); return Optional.ofNullable(fetchPendingJobInternal());
} }
@SqlQuery( @SqlQuery(
"SELECT id,jobType,methodName,jobArgs,status,createdAt,updatedAt,createdBy FROM background_jobs WHERE status = 'PENDING' ORDER BY createdAt LIMIT 1") "SELECT id,jobType,methodName,jobArgs,status,createdAt,updatedAt,createdBy,runAt FROM background_jobs"
+ " WHERE status = 'PENDING'"
+ " AND COALESCE(runAt, 0) <= UNIX_TIMESTAMP(NOW(3)) * 1000"
+ " ORDER BY createdAt LIMIT 1")
@RegisterRowMapper(BackgroundJobMapper.class) @RegisterRowMapper(BackgroundJobMapper.class)
BackgroundJob fetchPendingJobInternal() throws StatementException; BackgroundJob fetchPendingJobInternal() throws StatementException;

View File

@ -10,10 +10,17 @@ public class JobHandlerRegistry {
private final Map<String, JobHandler> handlers = new HashMap<>(); private final Map<String, JobHandler> handlers = new HashMap<>();
public void register(String methodName, JobHandler handler) { public void register(String methodName, JobHandler handler) {
LOG.info("Registering background job handler for: {}", handler.getClass().getSimpleName()); LOG.info(
"Registering background job handler for: {} -> {}",
handler.getClass().getSimpleName(),
handler.getClass().getCanonicalName());
handlers.put(methodName, handler); handlers.put(methodName, handler);
} }
public void register(JobHandler handler) {
register(handler.getClass().getSimpleName(), handler);
}
public JobHandler getHandler(BackgroundJob job) { public JobHandler getHandler(BackgroundJob job) {
String methodName = job.getMethodName(); String methodName = job.getMethodName();
Long jobId = job.getId(); Long jobId = job.getId();

View File

@ -107,7 +107,11 @@ public class AppResource extends EntityResource<App, AppRepository> {
static final String FIELDS = "owners"; static final String FIELDS = "owners";
private SearchRepository searchRepository; private SearchRepository searchRepository;
public static final List<ScheduleType> SCHEDULED_TYPES = public static final List<ScheduleType> SCHEDULED_TYPES =
List.of(ScheduleType.Scheduled, ScheduleType.ScheduledOrManual, ScheduleType.NoSchedule); List.of(
ScheduleType.Scheduled,
ScheduleType.ScheduledOrManual,
ScheduleType.NoSchedule,
ScheduleType.OnlyManual);
private final AppMapper mapper = new AppMapper(); private final AppMapper mapper = new AppMapper();
@Override @Override

View File

@ -99,7 +99,8 @@ public class JwtFilter implements ContainerRequestFilter {
"v1/users/generatePasswordResetLink", "v1/users/generatePasswordResetLink",
"v1/users/password/reset", "v1/users/password/reset",
"v1/users/login", "v1/users/login",
"v1/users/refresh"); "v1/users/refresh",
"v1/collate/apps/support/login");
@SuppressWarnings("unused") @SuppressWarnings("unused")
private JwtFilter() {} private JwtFilter() {}

View File

@ -6,11 +6,10 @@ import static org.junit.jupiter.api.Assertions.assertInstanceOf;
import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.openmetadata.service.jobs.GenericBackgroundWorker.NO_JOB_SLEEP_SECONDS;
import static org.openmetadata.service.security.SecurityUtil.getPrincipalName; import static org.openmetadata.service.security.SecurityUtil.getPrincipalName;
import static org.openmetadata.service.util.TestUtils.ADMIN_AUTH_HEADERS; import static org.openmetadata.service.util.TestUtils.ADMIN_AUTH_HEADERS;
import java.io.IOException;
import java.net.URISyntaxException;
import java.security.SecureRandom; import java.security.SecureRandom;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
@ -31,6 +30,7 @@ import org.openmetadata.service.OpenMetadataApplicationTest;
import org.openmetadata.service.jdbi3.CollectionDAO; import org.openmetadata.service.jdbi3.CollectionDAO;
import org.openmetadata.service.jobs.BackgroundJobException; import org.openmetadata.service.jobs.BackgroundJobException;
import org.openmetadata.service.jobs.EnumCleanupHandler; import org.openmetadata.service.jobs.EnumCleanupHandler;
import org.openmetadata.service.jobs.GenericBackgroundWorker;
import org.openmetadata.service.jobs.JobDAO; import org.openmetadata.service.jobs.JobDAO;
import org.openmetadata.service.jobs.JobHandler; import org.openmetadata.service.jobs.JobHandler;
import org.openmetadata.service.jobs.JobHandlerRegistry; import org.openmetadata.service.jobs.JobHandlerRegistry;
@ -50,11 +50,12 @@ public class BackgroundJobWorkerTest extends OpenMetadataApplicationTest {
public static CustomProperty customPropertyMulti; public static CustomProperty customPropertyMulti;
public static CustomProperty customPropertySingle; public static CustomProperty customPropertySingle;
public static GenericBackgroundWorker worker;
public static Table TABLE4; public static Table TABLE4;
@BeforeAll @BeforeAll
public static void setup(TestInfo test) throws IOException, URISyntaxException { public static void setup(TestInfo test) throws Exception {
registry = new JobHandlerRegistry(); registry = new JobHandlerRegistry();
jobDAO = Entity.getJobDAO(); jobDAO = Entity.getJobDAO();
collectionDao = Entity.getCollectionDAO(); collectionDao = Entity.getCollectionDAO();
@ -78,7 +79,7 @@ public class BackgroundJobWorkerTest extends OpenMetadataApplicationTest {
.withConfig( .withConfig(
Map.of( Map.of(
"values", "values",
List.of("single1", "single2", "single3", "single4", "\"single5\""), List.of("\"single5\"", "single1", "single2", "single3", "single4"),
"multiSelect", "multiSelect",
false))); false)));
@ -92,7 +93,7 @@ public class BackgroundJobWorkerTest extends OpenMetadataApplicationTest {
.withConfig( .withConfig(
Map.of( Map.of(
"values", "values",
List.of("multi1", "multi2", "multi3", "multi4", "\"multi5\""), List.of("\"multi5\"", "multi1", "multi2", "multi3", "multi4"),
"multiSelect", "multiSelect",
true))); true)));
CustomProperty[] customProperties = {customPropertySingle, customPropertyMulti}; CustomProperty[] customProperties = {customPropertySingle, customPropertyMulti};
@ -173,7 +174,6 @@ public class BackgroundJobWorkerTest extends OpenMetadataApplicationTest {
@Test @Test
public final void testBackgroundJobTriggerWithValidArgs() { public final void testBackgroundJobTriggerWithValidArgs() {
EnumCleanupArgs enumCleanupArgs = EnumCleanupArgs enumCleanupArgs =
new EnumCleanupArgs() new EnumCleanupArgs()
.withPropertyName(customPropertyMulti.getName()) .withPropertyName(customPropertyMulti.getName())
@ -204,4 +204,58 @@ public class BackgroundJobWorkerTest extends OpenMetadataApplicationTest {
assertEquals(enumCleanupArgs, actualArgs, "Job arguments should match"); assertEquals(enumCleanupArgs, actualArgs, "Job arguments should match");
assertEquals(job.getCreatedBy(), fetchedJob.getCreatedBy(), "Created by should match"); assertEquals(job.getCreatedBy(), fetchedJob.getCreatedBy(), "Created by should match");
} }
@Test
public final void testDelayedJobTrigger() throws InterruptedException {
// Create a delayed job for enum cleanup
EnumCleanupArgs enumCleanupArgs =
new EnumCleanupArgs()
.withPropertyName(customPropertyMulti.getName())
.withRemovedEnumKeys(List.of())
.withEntityType("table");
String jobArgs = JsonUtils.pojoToJson(enumCleanupArgs);
String createdBy = "admin";
long delayInMillis = 100; // 100ms delay
long jobId =
Entity.getJobDAO()
.insertJob(
BackgroundJob.JobType.CUSTOM_PROPERTY_ENUM_CLEANUP,
new EnumCleanupHandler(collectionDao),
jobArgs,
createdBy,
System.currentTimeMillis() + delayInMillis);
Optional<BackgroundJob> fetchedJobOptional = Entity.getJobDAO().fetchJobById(jobId);
assertTrue(fetchedJobOptional.isPresent(), "Delayed job should be present");
BackgroundJob fetchedJob = fetchedJobOptional.get();
assertEquals(
BackgroundJob.JobType.CUSTOM_PROPERTY_ENUM_CLEANUP,
fetchedJob.getJobType(),
"Job type should match");
assertEquals("EnumCleanupHandler", fetchedJob.getMethodName(), "Method name should match");
assertEquals(createdBy, fetchedJob.getCreatedBy(), "Created by should match");
// Verify the job arguments
EnumCleanupArgs actualArgs =
JsonUtils.readValue(JsonUtils.pojoToJson(fetchedJob.getJobArgs()), EnumCleanupArgs.class);
assertEquals(enumCleanupArgs, actualArgs, "Job arguments should match");
// Verify job is not executed immediately
Thread.sleep(delayInMillis - 50);
Optional<BackgroundJob> jobAfterShortWait = Entity.getJobDAO().fetchJobById(jobId);
assertTrue(jobAfterShortWait.isPresent(), "Job should still exist after short wait");
assertEquals(
BackgroundJob.Status.PENDING,
jobAfterShortWait.get().getStatus(),
"Job should not be completed yet");
// Wait for the next run cycle
Thread.sleep(delayInMillis + NO_JOB_SLEEP_SECONDS);
Optional<BackgroundJob> jobAfterDelay = Entity.getJobDAO().fetchJobById(jobId);
assertTrue(jobAfterDelay.isPresent(), "Job should still exist after delay");
assertEquals(
BackgroundJob.Status.COMPLETED, jobAfterDelay.get().getStatus(), "Job should be completed");
}
} }

View File

@ -15,7 +15,8 @@
"REFRESH_TOKEN", "REFRESH_TOKEN",
"EMAIL_VERIFICATION", "EMAIL_VERIFICATION",
"PASSWORD_RESET", "PASSWORD_RESET",
"PERSONAL_ACCESS_TOKEN" "PERSONAL_ACCESS_TOKEN",
"SUPPORT_TOKEN"
] ]
} }
}, },

View File

@ -0,0 +1,42 @@
{
"$id": "https://open-metadata.org/schema/auth/supportToken.json",
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "SupportToken",
"description": "This schema defines an access token used for support purposes. It is used only in Collate.",
"type": "object",
"javaType": "org.openmetadata.schema.auth.collate.SupportToken",
"javaInterfaces": ["org.openmetadata.schema.TokenInterface"],
"properties": {
"token": {
"description": "Unique Refresh Token for user",
"$ref": "../type/basic.json#/definitions/uuid"
},
"tokenName": {
"description": "Name of the token",
"type": "string"
},
"userId": {
"description": "User Id of the User this refresh token is given to",
"$ref": "../type/basic.json#/definitions/uuid"
},
"tokenType": {
"description": "Token Type",
"$ref": "./emailVerificationToken.json#/definitions/tokenType",
"default": "SUPPORT_TOKEN"
},
"expiryDate": {
"description": "Expiry Date-Time of the token",
"$ref": "../type/basic.json#/definitions/timestamp"
},
"jwtToken": {
"description": "JWT Auth Token.",
"type": "string"
}
},
"required": [
"token",
"userId",
"expiryDate"
],
"additionalProperties": false
}

View File

@ -3,7 +3,9 @@
"$schema": "http://json-schema.org/draft-07/schema#", "$schema": "http://json-schema.org/draft-07/schema#",
"title": "App", "title": "App",
"javaType": "org.openmetadata.schema.entity.app.App", "javaType": "org.openmetadata.schema.entity.app.App",
"javaInterfaces": ["org.openmetadata.schema.EntityInterface"], "javaInterfaces": [
"org.openmetadata.schema.EntityInterface"
],
"description": "This schema defines the applications for Open-Metadata.", "description": "This schema defines the applications for Open-Metadata.",
"type": "object", "type": "object",
"definitions": { "definitions": {
@ -15,20 +17,29 @@
"Live", "Live",
"Scheduled", "Scheduled",
"ScheduledOrManual", "ScheduledOrManual",
"NoSchedule" "NoSchedule",
"OnlyManual"
], ],
"javaEnums": [ "javaEnums": [
{ {
"name": "Live" "name": "Live",
"description": "An app that has other trigger mechanisms."
}, },
{ {
"name": "Scheduled" "name": "Scheduled",
"description": "An app that has a schedule and cannot be run manually."
}, },
{ {
"name": "ScheduledOrManual" "name": "ScheduledOrManual",
"description": "An app that has a schedule and can be run manually."
}, },
{ {
"name": "NoSchedule" "name": "NoSchedule",
"description": "An app that has no schedule and cannot be run manually."
},
{
"name": "OnlyManual",
"description": "An app that has no schedule but can be run manually."
} }
] ]
}, },
@ -36,7 +47,14 @@
"javaType": "org.openmetadata.schema.entity.app.ScheduleTimeline", "javaType": "org.openmetadata.schema.entity.app.ScheduleTimeline",
"description": "This schema defines the Application ScheduleTimeline Options", "description": "This schema defines the Application ScheduleTimeline Options",
"type": "string", "type": "string",
"enum": ["Hourly"," Daily", "Weekly", "Monthly", "Custom", "None"], "enum": [
"Hourly",
"Daily",
"Weekly",
"Monthly",
"Custom",
"None"
],
"default": "Weekly" "default": "Weekly"
}, },
"appSchedule": { "appSchedule": {
@ -51,7 +69,9 @@
"type": "string" "type": "string"
} }
}, },
"required": ["scheduleTimeline"], "required": [
"scheduleTimeline"
],
"additionalProperties": false "additionalProperties": false
}, },
"appType": { "appType": {

View File

@ -12,7 +12,7 @@
}, },
"jobType": { "jobType": {
"type": "string", "type": "string",
"enum": ["CUSTOM_PROPERTY_ENUM_CLEANUP"], "enum": ["CUSTOM_PROPERTY_ENUM_CLEANUP", "DELETE_ENTITY", "DELETE_TOKEN"],
"description": "Type of the job." "description": "Type of the job."
}, },
"methodName": { "methodName": {
@ -23,10 +23,18 @@
"oneOf": [ "oneOf": [
{ {
"$ref": "./enumCleanupArgs.json" "$ref": "./enumCleanupArgs.json"
},
{
"type": "object",
"additionalProperties": true
} }
], ],
"description": "Object containing job arguments." "description": "Object containing job arguments."
}, },
"runAt": {
"description": "Timestamp when the job was run in Unix epoch time milliseconds (default: as soon as possible).",
"$ref": "../type/basic.json#/definitions/timestamp"
},
"status": { "status": {
"type": "string", "type": "string",
"enum": ["COMPLETED", "FAILED", "RUNNING","PENDING"], "enum": ["COMPLETED", "FAILED", "RUNNING","PENDING"],

View File

@ -82,14 +82,17 @@ module.exports = {
moduleDirectories: ['node_modules', 'src'], moduleDirectories: ['node_modules', 'src'],
reporters: [ reporters: [
"default", 'default',
["jest-junit", { [
outputDirectory: "../../../../target/test-reports", 'jest-junit',
outputName: "jest-junit.xml", {
classNameTemplate: "{classname}", outputDirectory: '../../../../target/test-reports',
titleTemplate: "{title}", outputName: 'jest-junit.xml',
ancestorSeparator: " ", classNameTemplate: '{classname}',
usePathForSuiteName: "true" titleTemplate: '{title}',
}] ancestorSeparator: ' ',
] usePathForSuiteName: 'true',
},
],
],
}; };

View File

@ -26,10 +26,6 @@ jest.mock('../../hooks/useApplicationStore', () => {
}; };
}); });
jest.mock('../../utils/AuthProvider.util', () => ({
isProtectedRoute: jest.fn().mockReturnValue(true),
}));
jest.mock( jest.mock(
'../Settings/Applications/ApplicationsProvider/ApplicationsProvider', '../Settings/Applications/ApplicationsProvider/ApplicationsProvider',
() => { () => {

View File

@ -21,7 +21,6 @@ import { useApplicationStore } from '../../hooks/useApplicationStore';
import { getLimitConfig } from '../../rest/limitsAPI'; import { getLimitConfig } from '../../rest/limitsAPI';
import { getSettingsByType } from '../../rest/settingConfigAPI'; import { getSettingsByType } from '../../rest/settingConfigAPI';
import applicationRoutesClass from '../../utils/ApplicationRoutesClassBase'; import applicationRoutesClass from '../../utils/ApplicationRoutesClassBase';
import { isProtectedRoute } from '../../utils/AuthProvider.util';
import { LimitBanner } from '../common/LimitBanner/LimitBanner'; import { LimitBanner } from '../common/LimitBanner/LimitBanner';
import LeftSidebar from '../MyData/LeftSidebar/LeftSidebar.component'; import LeftSidebar from '../MyData/LeftSidebar/LeftSidebar.component';
import NavBar from '../NavBar/NavBar'; import NavBar from '../NavBar/NavBar';
@ -75,7 +74,8 @@ const AppContainer = () => {
{/* Render main content */} {/* Render main content */}
<Layout> <Layout>
{/* Render Appbar */} {/* Render Appbar */}
{isProtectedRoute(location.pathname) && isAuthenticated ? ( {applicationRoutesClass.isProtectedRoute(location.pathname) &&
isAuthenticated ? (
<NavBar /> <NavBar />
) : null} ) : null}

View File

@ -24,13 +24,15 @@ import PageNotFound from '../../pages/PageNotFound/PageNotFound';
import SignUpPage from '../../pages/SignUp/SignUpPage'; import SignUpPage from '../../pages/SignUp/SignUpPage';
import AppContainer from '../AppContainer/AppContainer'; import AppContainer from '../AppContainer/AppContainer';
import Loader from '../common/Loader/Loader'; import Loader from '../common/Loader/Loader';
import { UnAuthenticatedAppRouter } from './UnAuthenticatedAppRouter';
import { LogoutPage } from '../../pages/LogoutPage/LogoutPage'; import { LogoutPage } from '../../pages/LogoutPage/LogoutPage';
import SamlCallback from '../../pages/SamlCallback'; import SamlCallback from '../../pages/SamlCallback';
import applicationRoutesClass from '../../utils/ApplicationRoutesClassBase';
const AppRouter = () => { const AppRouter = () => {
const location = useCustomLocation(); const location = useCustomLocation();
const UnAuthenticatedAppRouter =
applicationRoutesClass.getUnAuthenticatedRouteElements();
// web analytics instance // web analytics instance
const analytics = useAnalytics(); const analytics = useAnalytics();

View File

@ -19,7 +19,7 @@ import { useApplicationStore } from '../../hooks/useApplicationStore';
import useCustomLocation from '../../hooks/useCustomLocation/useCustomLocation'; import useCustomLocation from '../../hooks/useCustomLocation/useCustomLocation';
import PageNotFound from '../../pages/PageNotFound/PageNotFound'; import PageNotFound from '../../pages/PageNotFound/PageNotFound';
import AccountActivationConfirmation from '../../pages/SignUp/account-activation-confirmation.component'; import AccountActivationConfirmation from '../../pages/SignUp/account-activation-confirmation.component';
import { isProtectedRoute } from '../../utils/AuthProvider.util'; import applicationRoutesClass from '../../utils/ApplicationRoutesClassBase';
import Auth0Callback from '../Auth/AppCallbacks/Auth0Callback/Auth0Callback'; import Auth0Callback from '../Auth/AppCallbacks/Auth0Callback/Auth0Callback';
import withSuspenseFallback from './withSuspenseFallback'; import withSuspenseFallback from './withSuspenseFallback';
@ -64,7 +64,7 @@ export const UnAuthenticatedAppRouter = () => {
} }
}, [authConfig?.provider]); }, [authConfig?.provider]);
if (isProtectedRoute(location.pathname)) { if (applicationRoutesClass.isProtectedRoute(location.pathname)) {
return <Redirect to={ROUTES.SIGNIN} />; return <Redirect to={ROUTES.SIGNIN} />;
} }

View File

@ -64,13 +64,13 @@ import {
fetchAuthorizerConfig, fetchAuthorizerConfig,
} from '../../../rest/miscAPI'; } from '../../../rest/miscAPI';
import { getLoggedInUser } from '../../../rest/userAPI'; import { getLoggedInUser } from '../../../rest/userAPI';
import applicationRoutesClass from '../../../utils/ApplicationRoutesClassBase';
import TokenService from '../../../utils/Auth/TokenService/TokenServiceUtil'; import TokenService from '../../../utils/Auth/TokenService/TokenServiceUtil';
import { import {
extractDetailsFromToken, extractDetailsFromToken,
getAuthConfig, getAuthConfig,
getUrlPathnameExpiry, getUrlPathnameExpiry,
getUserManagerConfig, getUserManagerConfig,
isProtectedRoute,
prepareUserProfileFromClaims, prepareUserProfileFromClaims,
} from '../../../utils/AuthProvider.util'; } from '../../../utils/AuthProvider.util';
import { import {
@ -209,7 +209,7 @@ export const AuthProvider = ({
}, []); }, []);
const handledVerifiedUser = () => { const handledVerifiedUser = () => {
if (!isProtectedRoute(location.pathname)) { if (!applicationRoutesClass.isProtectedRoute(location.pathname)) {
history.push(ROUTES.HOME); history.push(ROUTES.HOME);
} }
}; };
@ -404,7 +404,7 @@ export const AuthProvider = ({
* Stores redirect URL for successful login * Stores redirect URL for successful login
*/ */
const handleStoreProtectedRedirectPath = useCallback(() => { const handleStoreProtectedRedirectPath = useCallback(() => {
if (isProtectedRoute(location.pathname)) { if (applicationRoutesClass.isProtectedRoute(location.pathname)) {
storeRedirectPath(location.pathname); storeRedirectPath(location.pathname);
} }
}, [location.pathname, storeRedirectPath]); }, [location.pathname, storeRedirectPath]);

View File

@ -58,12 +58,16 @@ import { useTourProvider } from '../../context/TourProvider/TourProvider';
import { useWebSocketConnector } from '../../context/WebSocketProvider/WebSocketProvider'; import { useWebSocketConnector } from '../../context/WebSocketProvider/WebSocketProvider';
import { EntityTabs, EntityType } from '../../enums/entity.enum'; import { EntityTabs, EntityType } from '../../enums/entity.enum';
import { EntityReference } from '../../generated/entity/type'; import { EntityReference } from '../../generated/entity/type';
import { BackgroundJob, JobType } from '../../generated/jobs/backgroundJob'; import {
BackgroundJob,
EnumCleanupArgs,
JobType,
} from '../../generated/jobs/backgroundJob';
import { useCurrentUserPreferences } from '../../hooks/currentUserStore/useCurrentUserStore'; import { useCurrentUserPreferences } from '../../hooks/currentUserStore/useCurrentUserStore';
import useCustomLocation from '../../hooks/useCustomLocation/useCustomLocation'; import useCustomLocation from '../../hooks/useCustomLocation/useCustomLocation';
import { useDomainStore } from '../../hooks/useDomainStore'; import { useDomainStore } from '../../hooks/useDomainStore';
import { getVersion } from '../../rest/miscAPI'; import { getVersion } from '../../rest/miscAPI';
import { isProtectedRoute } from '../../utils/AuthProvider.util'; import applicationRoutesClass from '../../utils/ApplicationRoutesClassBase';
import brandClassBase from '../../utils/BrandData/BrandClassBase'; import brandClassBase from '../../utils/BrandData/BrandClassBase';
import { import {
hasNotificationPermission, hasNotificationPermission,
@ -259,6 +263,18 @@ const NavBar = () => {
const { jobArgs, status, jobType } = backgroundJobData; const { jobArgs, status, jobType } = backgroundJobData;
if (jobType === JobType.CustomPropertyEnumCleanup) { if (jobType === JobType.CustomPropertyEnumCleanup) {
const enumCleanupArgs = jobArgs as EnumCleanupArgs;
if (!enumCleanupArgs.entityType) {
showErrorToast(
{
isAxiosError: true,
message: 'Invalid job arguments: entityType is required',
} as AxiosError,
t('message.unexpected-error')
);
break;
}
body = t('message.custom-property-update', { body = t('message.custom-property-update', {
propertyName: jobArgs.propertyName, propertyName: jobArgs.propertyName,
entityName: jobArgs.entityType, entityName: jobArgs.entityType,
@ -267,7 +283,7 @@ const NavBar = () => {
path = getSettingPath( path = getSettingPath(
GlobalSettingsMenuCategory.CUSTOM_PROPERTIES, GlobalSettingsMenuCategory.CUSTOM_PROPERTIES,
getCustomPropertyEntityPathname(jobArgs.entityType) getCustomPropertyEntityPathname(enumCleanupArgs.entityType)
); );
} }
@ -303,7 +319,10 @@ const NavBar = () => {
} }
const handleDocumentVisibilityChange = async () => { const handleDocumentVisibilityChange = async () => {
if (isProtectedRoute(location.pathname) && isTourRoute) { if (
applicationRoutesClass.isProtectedRoute(location.pathname) &&
isTourRoute
) {
return; return;
} }
const newVersion = await getVersion(); const newVersion = await getVersion();

View File

@ -54,11 +54,9 @@ const AppSchedule = ({
const { config } = useLimitStore(); const { config } = useLimitStore();
const showRunNowButton = useMemo(() => { const showRunNowButton = useMemo(() => {
if (appData && appData.scheduleType === ScheduleType.ScheduledOrManual) { return [ScheduleType.ScheduledOrManual, ScheduleType.OnlyManual].includes(
return true; appData?.scheduleType
} );
return false;
}, [appData]); }, [appData]);
const { pipelineSchedules } = const { pipelineSchedules } =

View File

@ -54,4 +54,5 @@ export enum TokenType {
PasswordReset = "PASSWORD_RESET", PasswordReset = "PASSWORD_RESET",
PersonalAccessToken = "PERSONAL_ACCESS_TOKEN", PersonalAccessToken = "PERSONAL_ACCESS_TOKEN",
RefreshToken = "REFRESH_TOKEN", RefreshToken = "REFRESH_TOKEN",
SupportToken = "SUPPORT_TOKEN",
} }

View File

@ -0,0 +1,54 @@
/*
* Copyright 2025 Collate.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* This schema defines an access token used for support purposes. It is used only in Collate.
*/
export interface SupporToken {
/**
* Expiry Date-Time of the token
*/
expiryDate: number;
/**
* JWT Auth Token.
*/
jwtToken?: string;
/**
* Unique Refresh Token for user
*/
token: string;
/**
* Name of the token
*/
tokenName?: string;
/**
* Token Type
*/
tokenType?: TokenType;
/**
* User Id of the User this refresh token is given to
*/
userId: string;
}
/**
* Token Type
*
* Different Type of User token
*/
export enum TokenType {
EmailVerification = "EMAIL_VERIFICATION",
PasswordReset = "PASSWORD_RESET",
PersonalAccessToken = "PERSONAL_ACCESS_TOKEN",
RefreshToken = "REFRESH_TOKEN",
SupportToken = "SUPPORT_TOKEN",
}

View File

@ -1324,6 +1324,7 @@ export interface ExecutionContext {
export enum ScheduleType { export enum ScheduleType {
Live = "Live", Live = "Live",
NoSchedule = "NoSchedule", NoSchedule = "NoSchedule",
OnlyManual = "OnlyManual",
Scheduled = "Scheduled", Scheduled = "Scheduled",
ScheduledOrManual = "ScheduledOrManual", ScheduledOrManual = "ScheduledOrManual",
} }

View File

@ -39,6 +39,11 @@ export interface BackgroundJob {
* JobHandler name of the method that will be executed for this job. * JobHandler name of the method that will be executed for this job.
*/ */
methodName: string; methodName: string;
/**
* Timestamp when the job was run in Unix epoch time milliseconds (default: as soon as
* possible).
*/
runAt?: number;
/** /**
* Current status of the job. * Current status of the job.
*/ */
@ -58,15 +63,16 @@ export interface EnumCleanupArgs {
/** /**
* Type of the entity. * Type of the entity.
*/ */
entityType: string; entityType?: string;
/** /**
* Name of the property. * Name of the property.
*/ */
propertyName: string; propertyName?: string;
/** /**
* List of removed enum keys. * List of removed enum keys.
*/ */
removedEnumKeys: string[]; removedEnumKeys?: string[];
[property: string]: any;
} }
/** /**
@ -74,6 +80,8 @@ export interface EnumCleanupArgs {
*/ */
export enum JobType { export enum JobType {
CustomPropertyEnumCleanup = "CUSTOM_PROPERTY_ENUM_CLEANUP", CustomPropertyEnumCleanup = "CUSTOM_PROPERTY_ENUM_CLEANUP",
DeleteEntity = "DELETE_ENTITY",
DeleteToken = "DELETE_TOKEN",
} }
/** /**

View File

@ -13,11 +13,37 @@
import { FC } from 'react'; import { FC } from 'react';
import AuthenticatedAppRouter from '../components/AppRouter/AuthenticatedAppRouter'; import AuthenticatedAppRouter from '../components/AppRouter/AuthenticatedAppRouter';
import { UnAuthenticatedAppRouter } from '../components/AppRouter/UnAuthenticatedAppRouter';
import { ROUTES } from '../constants/constants';
class ApplicationRoutesClassBase { class ApplicationRoutesClassBase {
public getRouteElements(): FC { public getRouteElements(): FC {
return AuthenticatedAppRouter; return AuthenticatedAppRouter;
} }
public getUnAuthenticatedRouteElements(): FC {
return UnAuthenticatedAppRouter;
}
public isProtectedRoute(pathname: string): boolean {
return (
[
ROUTES.SIGNUP,
ROUTES.SIGNIN,
ROUTES.FORGOT_PASSWORD,
ROUTES.CALLBACK,
ROUTES.SILENT_CALLBACK,
ROUTES.SAML_CALLBACK,
ROUTES.REGISTER,
ROUTES.RESET_PASSWORD,
ROUTES.ACCOUNT_ACTIVATION,
ROUTES.HOME,
ROUTES.AUTH_CALLBACK,
ROUTES.NOT_FOUND,
ROUTES.LOGOUT,
].indexOf(pathname) === -1
);
}
} }
const applicationRoutesClass = new ApplicationRoutesClassBase(); const applicationRoutesClass = new ApplicationRoutesClassBase();

View File

@ -296,26 +296,6 @@ export const getNameFromUserData = (
return { name: userName, email: email, picture: user.picture }; return { name: userName, email: email, picture: user.picture };
}; };
export const isProtectedRoute = (pathname: string) => {
return (
[
ROUTES.SIGNUP,
ROUTES.SIGNIN,
ROUTES.FORGOT_PASSWORD,
ROUTES.CALLBACK,
ROUTES.SILENT_CALLBACK,
ROUTES.SAML_CALLBACK,
ROUTES.REGISTER,
ROUTES.RESET_PASSWORD,
ROUTES.ACCOUNT_ACTIVATION,
ROUTES.HOME,
ROUTES.AUTH_CALLBACK,
ROUTES.NOT_FOUND,
ROUTES.LOGOUT,
].indexOf(pathname) === -1
);
};
export const isTourRoute = (pathname: string) => { export const isTourRoute = (pathname: string) => {
return pathname === ROUTES.TOUR; return pathname === ROUTES.TOUR;
}; };

View File

@ -28,8 +28,8 @@ import ptBR from '../../locale/languages/pt-br.json';
import ptPT from '../../locale/languages/pt-pt.json'; import ptPT from '../../locale/languages/pt-pt.json';
import ruRU from '../../locale/languages/ru-ru.json'; import ruRU from '../../locale/languages/ru-ru.json';
import thTH from '../../locale/languages/th-th.json'; import thTH from '../../locale/languages/th-th.json';
import zhCN from '../../locale/languages/zh-cn.json';
import trTR from '../../locale/languages/tr-tr.json'; import trTR from '../../locale/languages/tr-tr.json';
import zhCN from '../../locale/languages/zh-cn.json';
export enum SupportedLocales { export enum SupportedLocales {
English = 'en-US', English = 'en-US',