diff --git a/Jenkinsfile b/Jenkinsfile index 5329540931..4557587319 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -121,7 +121,7 @@ pipeline { parallel { stage ('1: run first test suite') { steps { - sh script: "make -j$NPROC k3d/retest TESTS=[api,deploytarget,active-standby-kubernetes,features-kubernetes,features-kubernetes-2,features-variables] BRANCH_NAME=${SAFEBRANCH_NAME}", label: "Running first test suite on k3d cluster" + sh script: "make -j$NPROC k3d/retest TESTS=[api,api-routes,deploytarget,active-standby-kubernetes,features-kubernetes,features-kubernetes-2,features-variables] BRANCH_NAME=${SAFEBRANCH_NAME}", label: "Running first test suite on k3d cluster" sh script: "pkill -f './local-dev/stern'", label: "Closing off test-suite-1 log after test completion" } } diff --git a/Makefile b/Makefile index 2ce666611c..b9594b9b4e 100644 --- a/Makefile +++ b/Makefile @@ -462,7 +462,7 @@ JWT_VERSION = 6.2.0 STERN_VERSION = v2.6.1 CHART_TESTING_VERSION = v3.11.0 K3D_IMAGE = docker.io/rancher/k3s:v1.31.1-k3s1 -TESTS = [nginx,api,features-kubernetes,bulk-deployment,features-kubernetes-2,features-variables,active-standby-kubernetes,tasks,drush,python,gitlab,github,bitbucket,services] +TESTS = [nginx,api,api-routes,features-kubernetes,bulk-deployment,features-kubernetes-2,features-variables,active-standby-kubernetes,tasks,drush,python,gitlab,github,bitbucket,services] CHARTS_TREEISH = main CHARTS_REPOSITORY = https://github.com/uselagoon/lagoon-charts.git #CHARTS_REPOSITORY = ../lagoon-charts diff --git a/node-packages/commons/src/api.ts b/node-packages/commons/src/api.ts index caba28e54a..d89b67817e 100644 --- a/node-packages/commons/src/api.ts +++ b/node-packages/commons/src/api.ts @@ -43,6 +43,26 @@ export interface Project { standbyRoutes: string; storageCalc: number; subfolder: string; + apiRoutes: any[]; + autogeneratedRouteConfig: AutogeneratedRouteConfig; +} + +export interface AutogeneratedRouteConfig { + updated: string; + pathRoutes: AutogeneratedPathRoutes[]; + disableRequestVerification: boolean; + enabled?: boolean; + allowPullRequests?: boolean; + insecure?: string; + prefixes?: string[]; + tlsAcme?: boolean; + // ingressClass?: string; +} + +export interface AutogeneratedPathRoutes { + toService: string; + fromService: string; + path: string; } export interface Kubernetes { @@ -681,6 +701,51 @@ export const allProjectsInGroup = (groupInput: { } ); + +const apiRouteFragment = graphqlapi.createFragment(` +fragment on Route { + domain + service + alternativeNames{ + domain + } + annotations{ + key + value + } + pathRoutes{ + toService + path + } + tlsAcme + insecure + hstsEnabled + hstsIncludeSubdomains + hstsPreload + hstsMaxAge + primary + source + type +} +`); + +const autogeneratedRouteConfigFragment = graphqlapi.createFragment(` +fragment on AutogeneratedRouteConfig { + updated + enabled + allowPullRequests + prefixes + tlsAcme + insecure + pathRoutes { + toService + fromService + path + } + disableRequestVerification +} +`); + export async function getEnvironmentByName( name: string, projectId: number, @@ -697,9 +762,23 @@ export async function getEnvironmentByName( autoIdle, environmentType, openshiftProjectName, + openshift { + ...${deployTargetMinimalFragment} + } updated, created, deleted, + envVariables { + name + value + scope + } + apiRoutes(source: API){ + ...${apiRouteFragment} + } + autogeneratedRouteConfig { + ...${autogeneratedRouteConfigFragment} + } } } `); @@ -733,6 +812,12 @@ export async function getEnvironmentByIdWithVariables( value scope } + apiRoutes(source: API){ + ...${apiRouteFragment} + } + autogeneratedRouteConfig { + ...${autogeneratedRouteConfigFragment} + } } } `); @@ -846,6 +931,8 @@ interface GetOpenshiftInfoForProjectResult { | 'standbyRoutes' | 'storageCalc' | 'subfolder' + | 'apiRoutes' + | 'autogeneratedRouteConfig' > & { openshift: Pick; envVariables: Pick[]; @@ -885,6 +972,12 @@ export const getOpenShiftInfoForProject = (project: string): Promise & { openshift: Pick; envVariables: Pick[]; }, - environment: { envVariables: Pick[]}, + environment: { + envVariables: Pick[] + apiRoutes, + autogeneratedRouteConfig, + }, deployTarget: Pick, bulkId: string | null, bulkName: string | null, buildPriority: number, buildVariables: Array<{name: string, value: string}>, - bulkTask: bulkType + bulkTask: bulkType, ): Promise<{ routerPattern: string, appliedEnvVars: string }> { type EnvKeyValueInternal = Pick & { scope: EnvVariableScope | InternalEnvVariableScope; } + /* + prepares the values to send to builds and tasks for consumption + this ensures the data is structured to match the one that the build expects when it receives the payload + this gets based64 encoded before being shipped. it is decoded during the build + */ + let apiRoutes = environment.apiRoutes + if (apiRoutes.length > 0) { + for (let i = 0; i < apiRoutes.length; i++) { + // remove any yaml/autogenerated sourced apiRoutes from the list + // these don't get send to builds from the api as they are configured by lagoon.yml + // or are created by the autogenerated route configuration + if ([RouteSource.YAML, RouteSource.AUTOGENERATED].includes(apiRoutes[i].source.toLowerCase())) { + // the environment queries have (source: API) on the environments apiRoutes request + // this check is just a fallback check + apiRoutes.splice(i, 1); + i--; + continue; + } + // the structure of annotations in the build-deploy-tool are `map[string]string` + // this converts the `key:value` type from the api to `map[string]string` for the build-deploy-tool to consume + let annotations = apiRoutes[i].annotations.reduce((acc, item) => { + acc[item.key] = item.value; + return acc; + }, {}); + apiRoutes[i].annotations = annotations + // the structure of alternativeNames in the build-deploy-tool are `[]string` + // this converts to that type for the build-deploy-tool to consume + let alternativeNames = apiRoutes[i].alternativeNames.map(item => item.domain); + apiRoutes[i].alternativeNames = alternativeNames + } + } + + /* + this handles creating the autogenerated route configuration if any autogenerated route settings + on the project or environment are set, this then gets setup in the required format + and base64 encoded before being sent to the build, it is decoded there + */ + let agPayload = {} + if (project.autogeneratedRouteConfig !== null) { + agPayload = project.autogeneratedRouteConfig + } + if (environment.autogeneratedRouteConfig !== null) { + agPayload = environment.autogeneratedRouteConfig + } // Single list of env vars that apply to an environment. Env vars from multiple // sources (organization, project, enviornment, build vars, etc) are // consolidated based on precedence. @@ -711,6 +763,37 @@ export const getEnvironmentsRouterPatternAndVariables = async function( }); } + // `LAGOON_API_ROUTES` is the successor to LAGOON_ROUTES_JSON. the build-deploy-tool will + // check if `LAGOON_ROUTES_JSON` is defined, then that will be used to prevent breaking deployments + // but will produce a build warning indicating that LAGOON_ROUTES_JSON will be deprecated in the future + // and to use API defined routes instead + if (apiRoutes.length > 0) { + applyIfNotExists({ + name: "LAGOON_API_ROUTES", + value: encodeJSONBase64({routes: apiRoutes}), + scope: InternalEnvVariableScope.INTERNAL_SYSTEM + }); + } + if (project.apiRoutes.length > 0) { + // if the project has any apiroutes defined, then we enforce cleanup of removed routes on any environments + applyIfNotExists({ + name: "LAGOON_API_ROUTES_CLEANUP", + value: "true", + scope: InternalEnvVariableScope.INTERNAL_SYSTEM + }); + } + if (Object.keys(agPayload).length) { + // if there is any autogenerated route configuration to send + // set that here + // @TODO: need to figure out a better way to handle the default autogenerate insecure handling + // agPayload.autogenerate.insecure = "Redirect" + applyIfNotExists({ + name: "LAGOON_API_AUTOGENERATED_CONFIG", + value: encodeJSONBase64(agPayload), + scope: InternalEnvVariableScope.INTERNAL_SYSTEM + }); + } + /* * Normally scoped env vars. * @@ -1076,7 +1159,7 @@ export const getTaskProjectEnvironmentVariables = async (projectName: string, en result.project, environment.environmentById, environment.environmentById.openshift, - null, null, priority, [], bulkType.Task // bulk deployments don't apply to tasks yet, but this is future proofing the function call + null, null, priority, [], bulkType.Task, // bulk deployments don't apply to tasks yet, but this is future proofing the function call ) return appliedEnvVars } diff --git a/node-packages/commons/src/types.ts b/node-packages/commons/src/types.ts index 10dc2744d5..be1a898b7a 100644 --- a/node-packages/commons/src/types.ts +++ b/node-packages/commons/src/types.ts @@ -58,6 +58,18 @@ export enum AuditType { FILE = 'file', } +export enum RouteType { + STANDARD = 'standard', + STANDBY = 'standby', + ACTIVE = 'active' +} + +export enum RouteSource { + API = 'api', + YAML = 'yaml', + AUTOGENERATED = 'autogenerated' +} + export interface DeployData { baseBranchName?: string, baseSha?: string, diff --git a/node-packages/commons/src/util/func.ts b/node-packages/commons/src/util/func.ts index 6207081db6..f6ca62e363 100644 --- a/node-packages/commons/src/util/func.ts +++ b/node-packages/commons/src/util/func.ts @@ -122,3 +122,60 @@ export async function generatePrivateKey(): Promise { return (await response.json()) as PrivateKeyResponse; } + +// https://github.com/kubernetes/apimachinery/blob/v0.34.1/pkg/util/validation/validation.go#L219 +// javascript implementation of the function used by kubernetes to verify ingress +const dns1123LabelFmt: string = "[a-z0-9]([-a-z0-9]*[a-z0-9])?"; +const dns1123SubdomainFmt: string = `${dns1123LabelFmt}(\\.${dns1123LabelFmt})*`; +const dns1123SubdomainMaxLength: number = 253; +export function isDNS1123Subdomain(value: string): boolean { + if (value.length > dns1123SubdomainMaxLength) { + return false; + } + + const dns1123SubdomainRegexp = new RegExp(dns1123SubdomainFmt); + + if (!dns1123SubdomainRegexp.test(value)) { + return false; + } + return true; +} + +// https://github.com/kubernetes/apimachinery/blob/v0.34.1/pkg/util/validation/validation.go#L41 +// javascript implementation of the function used by kubernetes to verify annotation/label keys +const labelKeyCharFmt = "[A-Za-z0-9]"; +const labelKeyExtCharFmt = "[-A-Za-z0-9_.]"; +const labelKeyFmt = `(${labelKeyCharFmt}${labelKeyExtCharFmt}*)?${labelKeyCharFmt}`; +const labelKeyErrMsg = "must consist of alphanumeric characters, '-', '_' or '.', and must start and end with an alphanumeric character"; +const labelKeyMaxLength = 63; +const labelKeyRegexp = new RegExp(`^${labelKeyFmt}$`); +export function isLabelKey(value: string) { + const errs = []; + const parts = value.split("/"); + let name; + switch (parts.length) { + case 1: + name = parts[0]; + break; + case 2: + const prefix = parts[0]; + name = parts[1]; + if (prefix.length === 0) { + errs.push("prefix part cannot be empty"); + } else if (!isDNS1123Subdomain(prefix)) { + errs.push(`prefix part ${prefix} must be a valid DNS subdomain`); + } + break; + default: + return errs.concat(`a valid label key ${labelKeyErrMsg} with an optional DNS subdomain prefix and '/' (e.g. 'example.com/MyName')`); + } + if (name.length === 0) { + errs.push("name part cannot be empty"); + } else if (name.length > labelKeyMaxLength) { + errs.push(`name part exceeds maximum length of ${labelKeyMaxLength}`); + } + if (!labelKeyRegexp.test(name)) { + errs.push(`name part ${labelKeyErrMsg}`); + } + return errs; +} \ No newline at end of file diff --git a/services/actions-handler/handler/controller_tasks.go b/services/actions-handler/handler/controller_tasks.go index 6a8bd78634..d6765000ac 100644 --- a/services/actions-handler/handler/controller_tasks.go +++ b/services/actions-handler/handler/controller_tasks.go @@ -84,6 +84,8 @@ func (m *Messenger) handleTask(ctx context.Context, messageQueue *mq.MessageQueu } return err } + // if the project has API defined routes, handle updating those here + updateActiveStandbyRoutes(l, message.Meta.Project, *updateProject.ProductionEnvironment, *updateProject.StandbyProductionEnvironment, prefix) log.Printf("%supdated project %s with active/standby result: %v", prefix, message.Meta.Project, "success") } } @@ -120,3 +122,81 @@ func (m *Messenger) handleTask(ctx context.Context, messageQueue *mq.MessageQueu log.Printf("%supdated task: %s", prefix, message.Meta.JobStatus) return nil } + +/* +these will need to be added to machinery at some point +for now they're defined here and adding or extending to existing schema +*/ +type Project struct { + schema.Project + // @TODO: add to machinery + APIRoutes []Route `json:"apiRoutes"` +} + +// @TODO: add to machinery +type Route struct { + Domain string `json:"domain"` + Service string `json:"service"` + Environment *schema.Environment `json:"environment"` + Type string `json:"type"` +} + +/* +updateActiveStandbyRoutes will update routes in the project according to the result +of the active/standby switch, it calls updateRouteType accordingly +*/ +func updateActiveStandbyRoutes(l *lclient.Client, project, prod, standby, prefix string) { + // get routes of the project + raw := fmt.Sprintf(`query pbn{ + projectByName(name:"%s"){ + id + apiRoutes{ + domain + service + environment{ + name + } + type + } + } + }`, project) + rawResp, err := l.ProcessRaw(context.TODO(), raw, nil) + if err != nil { + log.Printf("%sERROR: unable to update task: %v", prefix, err) + return + } + var p Project + d, _ := json.Marshal(rawResp.(map[string]interface{})["projectByName"]) + json.Unmarshal(d, &p) + for _, route := range p.APIRoutes { + // only patch routes with an environment attached + if route.Type == "ACTIVE" && route.Environment != nil { + updateRouteType(l, route, prod, project, prefix) + } + if route.Type == "STANDBY" && route.Environment != nil { + updateRouteType(l, route, standby, project, prefix) + } + } +} + +/* +updateRouteType will just update which environment the route is attached to +based on the result of the active/standby task completion +*/ +func updateRouteType(l *lclient.Client, route Route, env, project, prefix string) { + raw := fmt.Sprintf(`mutation moveRoute{ + activeStandbyRouteMove( + input:{ + domain: "%s" + environment: "%s" + project: "%s" + }){ + id + } + }`, route.Domain, env, project) + _, err := l.ProcessRaw(context.TODO(), raw, nil) + if err != nil { + log.Printf("%sERROR: unable to update task: %v", prefix, err) + return + } +} diff --git a/services/api/database/migrations/20251030000000_routes.js b/services/api/database/migrations/20251030000000_routes.js new file mode 100644 index 0000000000..93181e8606 --- /dev/null +++ b/services/api/database/migrations/20251030000000_routes.js @@ -0,0 +1,86 @@ +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.up = async function(knex) { + const route = await knex.schema.hasTable('routes'); + if (!route) { + return knex.schema + .createTable('routes', function (table) { + table.increments('id').notNullable().primary(); + table.timestamp('created').notNullable().defaultTo(knex.fn.now()); + table.timestamp('updated').notNullable().defaultTo(knex.fn.now()); + table.string('domain', 253).notNullable(); + table.integer('project').notNullable(); + table.integer('environment'); + table.string('service', 300); + table.boolean('tls_acme').notNullable().defaultTo(1); // default to true + table.enu('insecure', ['Allow', 'Redirect', 'None']).notNullable().defaultTo('Redirect'); + table.string('monitoring_path', 300); + table.boolean('hsts_enabled').notNullable().defaultTo(0); // default to false + table.boolean('hsts_include_subdomains').notNullable().defaultTo(0); + table.boolean('hsts_preload').notNullable().defaultTo(0); // default to false + table.integer('hsts_max_age').defaultTo(3153600); + table.boolean('primary').notNullable().defaultTo(0); // default to false + table.boolean('disable_request_verification').notNullable().defaultTo(0); // default to false + table.text('path_routes'); + table.enu('source', ['api','yaml','autogenerated']).notNullable().defaultTo('api'); + table.enu('type', ['standard', 'active', 'standby']).notNullable().defaultTo('standard'); + // table.boolean('wildcard').notNullable().defaultTo(0); // TBD + // table.boolean('wildcard_apex').notNullable().defaultTo(0); // TBD + // table.enu('verified', ['new','pending', 'running', 'error', 'verified']).notNullable().defaultTo('new'); // TBD + // table.json('verification').defaultTo('{}'); //TBD + // table.string('ingress_class', 300); // TBD + table.unique(['domain', 'project'], {indexName: 'route_project'}); + }) + .createTable('routes_alternate_domain', function (table) { + table.increments('id').notNullable().primary(); + table.integer('route_id'); //route id + table.string('domain', 253).notNullable(); + // table.json('verification').defaultTo('{}'); //TBD + table.unique(['domain', 'route_id'], {indexName: 'alternate_domain_route'}); + }) + .createTable('routes_annotations', function (table) { + table.increments('id').notNullable().primary(); + table.integer('route_id'); + table.string('key', 63).notNullable(); + table.text('value'); + table.unique(['key', 'route_id'], {indexName: 'alternate_domain_route'}); + }) + .createTable('routes_autogenerated_configuration', (table) => { + table.increments('id').notNullable().primary(); + table.enu('type', ['project', 'environment']); + table.integer('type_id'); + table.timestamp('updated').notNullable().defaultTo(knex.fn.now()); + table.boolean('enabled').notNullable().defaultTo(1); // default to true + table.boolean('allow_pull_requests').notNullable().defaultTo(1); // default to true + table.boolean('tls_acme').notNullable().defaultTo(1); // default to true + table.enu('insecure', ['Allow', 'Redirect', 'None']).notNullable().defaultTo('Redirect'); + table.text('prefixes') // would be comma separated list of prefixes representing what is in the .lagoon.yml, would override lagoon.yml if defined + table.text('path_routes') // would be JSON equivalent of what is in lagoon.yml, would override lagoon.yml if defined + table.boolean('disable_request_verification').notNullable().defaultTo(0); // default to false + table.unique(['type', 'type_id'], {indexName: 'autogenerated_route_config_type'}); + }) + .alterTable('organization', function (table) { + table.boolean('feature_api_routes').defaultTo(0); // disable api routes feature by default, future release of lagoon will remove this when routes api becomes generally available + }) + } + else { + return knex.schema + } +}; + +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.down = async function(knex) { + return knex.schema + .dropTable('routes') + .dropTable('routes_alternate_domain') + .dropTable('routes_annotations') + .dropTable('routes_autogenerated_configuration') + .alterTable('organization', (table) => { + table.dropColumn('feature_api_routes'); + }); +}; \ No newline at end of file diff --git a/services/api/src/resolvers.js b/services/api/src/resolvers.js index 027f495c3b..42cb509e4b 100644 --- a/services/api/src/resolvers.js +++ b/services/api/src/resolvers.js @@ -195,6 +195,7 @@ const { removeProjectMetadataByKey, getPrivateKey, getProjectDeployKey, + getFeatureApiRoutes } = require('./resources/project/resolvers'); const { @@ -314,6 +315,34 @@ const { getEnvVariablesByProjectEnvironmentName, } = require('./resources/env-variables/resolvers'); +const { + addRouteToProject, + deleteRoute, + getRoutesByProjectId, + getRoutesByEnvironmentId, + getAlternateRoutesByRouteId, + getRouteAnnotationsByRouteId, + getPathRoutesByRouteId, + addRouteAlternativeDomains, + removeRouteAlternativeDomain, + addRouteAnnotation, + removeRouteAnnotation, + addPathRoutesToRoute, + removePathRouteFromRoute, + addOrUpdateRouteOnEnvironment, + activeStandbyRouteMove, + removeRouteFromEnvironment, + updateRouteOnProject, + getAutogeneratedRouteConfigByProjectId, + getAutogeneratedRouteConfigByEnvironmentId, + getAutogeneratedRoutePrefixes, + getAutogeneratedPathRoutes, + updateAutogeneratedRouteConfigOnProject, + updateAutogeneratedRouteConfigOnEnvironment, + removeAutogeneratedRouteConfigFromProject, + removeAutogeneratedRouteConfigFromEnvironment, +} = require('./resources/routes/resolvers'); + async function getResolvers() { let graphqlUpload; try { @@ -460,6 +489,21 @@ async function getResolvers() { HARBOR: 'harbor', HISTORY: 'history', }, + RouteSource: { + API: 'api', + YAML: 'yaml', + AUTOGENERATED: 'autogenerated', + }, + RouteType: { + STANDARD: 'standard', + ACTIVE: 'active', + STANDBY: 'standby', + }, + RouteInsecure: { + Allow: 'Allow', + Redirect: 'Redirect', + None: 'None', + }, Openshift: { projectUser: getProjectUser, token: getToken, @@ -484,6 +528,13 @@ async function getResolvers() { publicKey: getProjectDeployKey, organizationDetails: getOrganizationByProject, retentionPolicies: getRetentionPoliciesByProjectId, + apiRoutes: getRoutesByProjectId, + autogeneratedRouteConfig: getAutogeneratedRouteConfigByProjectId, + featureApiRoutes: getFeatureApiRoutes, + }, + AutogeneratedRouteConfig: { + prefixes: getAutogeneratedRoutePrefixes, + pathRoutes: getAutogeneratedPathRoutes, }, GroupInterface: { __resolveType(group) { @@ -527,6 +578,15 @@ async function getResolvers() { openshift: getOpenshiftByEnvironmentId, kubernetes: getOpenshiftByEnvironmentId, pendingChanges: getPendingChangesByEnvironmentId, + apiRoutes: getRoutesByEnvironmentId, + autogeneratedRouteConfig: getAutogeneratedRouteConfigByEnvironmentId, + }, + Route: { + alternativeNames: getAlternateRoutesByRouteId, + annotations: getRouteAnnotationsByRouteId, + pathRoutes: getPathRoutesByRouteId, + environment: getEnvironmentById, + project: getProjectById, }, Organization: { groups: getGroupsByOrganizationId, @@ -817,6 +877,22 @@ async function getResolvers() { deleteHistoryRetentionPolicy, addHistoryRetentionPolicyLink, removeHistoryRetentionPolicyLink, + addRouteToProject, + addOrUpdateRouteOnEnvironment, + updateRouteOnProject, + activeStandbyRouteMove, + removeRouteFromEnvironment, + addRouteAlternativeDomains, + removeRouteAlternativeDomain, + deleteRoute, + addRouteAnnotation, + removeRouteAnnotation, + addPathRoutesToRoute, + removePathRouteFromRoute, + updateAutogeneratedRouteConfigOnProject, + updateAutogeneratedRouteConfigOnEnvironment, + removeAutogeneratedRouteConfigFromProject, + removeAutogeneratedRouteConfigFromEnvironment, }, Subscription: { backupChanged: backupSubscriber, diff --git a/services/api/src/resources/environment/helpers.ts b/services/api/src/resources/environment/helpers.ts index 1645e88da5..ed26a02e96 100644 --- a/services/api/src/resources/environment/helpers.ts +++ b/services/api/src/resources/environment/helpers.ts @@ -7,6 +7,7 @@ import { Sql as problemSql } from '../problem/sql'; import { Sql as factSql } from '../fact/sql'; // import { Sql as backupSql } from '../backup/sql'; import { Helpers as projectHelpers } from '../project/helpers'; +import { Helpers as routeHelpers } from '../routes/helpers'; import { HistoryRetentionEnforcer } from '../retentionpolicy/history'; import { logger } from '../../loggers/logger'; @@ -91,6 +92,17 @@ export const Helpers = (sqlClientPool: Pool) => { problemSql.deleteProblemsForEnvironment(eid) ); + // remove all route associations from environment + // delete any autogenerated routes, these are not reusable and are only associated to the environment they are on + await routeHelpers(sqlClientPool).deleteAutogeneratedRoutesForEnvironment(eid) + // delete any lagoon.yml managed routes from the api, if they want to manage them in the api + // they should be modified in the api to update the source to the api to nullify the lagoon.yml source + await routeHelpers(sqlClientPool).deleteLagoonYAMLRoutesForEnvironment(eid) + // but leave the routes in the api attached to the project + await routeHelpers(sqlClientPool).removeAllRoutesFromEnvironment(eid) + // delete any autogenerated route configuration for this environment + await routeHelpers(sqlClientPool).deleteAutogeneratedRouteConfigForEnvironment(eid); + // delete the environment backups rows // logger.debug(`deleting environment ${name}/id:${eid}/project:${pid} environment backups`) // @TODO: this could be done here, but it would mean that to recover all the backup ids of a deleted environment diff --git a/services/api/src/resources/environment/resolvers.ts b/services/api/src/resources/environment/resolvers.ts index 10e79bd738..b98bc178bb 100644 --- a/services/api/src/resources/environment/resolvers.ts +++ b/services/api/src/resources/environment/resolvers.ts @@ -46,11 +46,16 @@ export const getEnvironmentByName: ResolverFn = async ( }; export const getEnvironmentById = async ( - root, + eid, args, { sqlClientPool, hasPermission, adminScopes } ) => { - const environment = await Helpers(sqlClientPool).getEnvironmentById(args.id); + // handle dealing with arg or passthrough + let environmentId = args.id + if (eid) { + environmentId = eid.environment + } + const environment = await Helpers(sqlClientPool).getEnvironmentById(environmentId); if (!environment) { return null; @@ -751,7 +756,7 @@ export const updateEnvironment: ResolverFn = async ( route: input.patch.route, routes: input.patch.routes, autoIdle: input.patch.autoIdle, - created: input.patch.created + created: input.patch.created, } }) ); @@ -1059,10 +1064,7 @@ export const getEnvironmentServicesByEnvironmentId: ResolverFn = async ( args, { sqlClientPool } ) => { - const rows = await query( - sqlClientPool, - Sql.selectServicesByEnvironmentId(eid) - ); + const rows = await Helpers(sqlClientPool).getEnvironmentServices(eid) return rows; }; diff --git a/services/api/src/resources/organization/resolvers.ts b/services/api/src/resources/organization/resolvers.ts index 8ea4724288..1830fba23e 100644 --- a/services/api/src/resources/organization/resolvers.ts +++ b/services/api/src/resources/organization/resolvers.ts @@ -221,7 +221,7 @@ export const getEnvironmentsByOrganizationId: ResolverFn = async ( export const updateOrganization: ResolverFn = async ( root, { input }, - { sqlClientPool, hasPermission, userActivityLogger } + { sqlClientPool, hasPermission, userActivityLogger, adminScopes } ) => { if (input.patch.quotaProject || input.patch.quotaGroup || input.patch.quotaNotification || input.patch.quotaEnvironment || input.patch.quotaRoute) { @@ -230,6 +230,15 @@ export const updateOrganization: ResolverFn = async ( await hasPermission('organization', 'updateOrganization', input.id); } + // if the beta feature flag for api routes is being updated, check if permission to enable this flag is set + // this feature will become generally available in a future version and this flag will not + // be required + if (input.patch.featureApiRoutes) { + if (!adminScopes.platformOwner) { + throw new Error('Setting the api routes feature is only available to platform administrators.'); + } + } + if (input.patch.name) { // check if the name is valid isValidName(input.patch.name) diff --git a/services/api/src/resources/project/helpers.ts b/services/api/src/resources/project/helpers.ts index 6821493144..b53df73cd6 100644 --- a/services/api/src/resources/project/helpers.ts +++ b/services/api/src/resources/project/helpers.ts @@ -5,6 +5,8 @@ import { query } from '../../util/db'; import { Sql } from './sql'; import { Sql as environmentSql } from '../environment/sql'; import { Sql as backupSql } from '../backup/sql'; +import { Helpers as organizationHelpers } from '../organization/helpers'; +import { Helpers as routeHelpers } from '../routes/helpers'; // import { logger } from '../../loggers/logger'; export const Helpers = (sqlClientPool: Pool) => { @@ -74,7 +76,8 @@ export const Helpers = (sqlClientPool: Pool) => { const getProjectByName = async (name: string) => { const rows = await query(sqlClientPool, Sql.selectProjectByName(name)); - return R.prop(0, rows); + const withK8s = aliasOpenshiftToK8s(rows); + return R.prop(0, withK8s); }; const getProjectByEnvironmentId = async ( @@ -101,14 +104,25 @@ export const Helpers = (sqlClientPool: Pool) => { const getProjectsByIds = (projectIds: number[]) => query(sqlClientPool, Sql.selectProjectsByIds(projectIds)); + const checkApiRoutesFeature = async (organizationId: number) => { + const organization = await organizationHelpers(sqlClientPool).getOrganizationById(organizationId); + if (!organization) { + // projects not in an organization can use api routes + return true; + } + return Boolean(organization.featureApiRoutes); + }; + return { checkOrgProjectViewPermission, checkOrgProjectUpdatePermission, aliasOpenshiftToK8s, getProjectById, + getProjectByName, getProjectsByIds, getProjectByEnvironmentId, getProjectByOrganizationId, + checkApiRoutesFeature, getProjectIdByName: async (name: string): Promise => { const pidResult = await query( sqlClientPool, @@ -216,6 +230,8 @@ export const Helpers = (sqlClientPool: Pool) => { sqlClientPool, environmentSql.deleteEnvironmentsByProjectID(id) ); + // delete any autogenerated route configuration for this project + await routeHelpers(sqlClientPool).deleteAutogeneratedRouteConfigForProject(id); // logger.debug(`deleting project ${id}`) // delete the project await query( diff --git a/services/api/src/resources/project/resolvers.ts b/services/api/src/resources/project/resolvers.ts index c52c589414..744f0cd0eb 100644 --- a/services/api/src/resources/project/resolvers.ts +++ b/services/api/src/resources/project/resolvers.ts @@ -268,6 +268,7 @@ export const addProject = async ( throw new Error('The provided deploytarget is not valid for this organization'); } } + } else { if (DISABLE_NON_ORGANIZATION_PROJECT_CREATION == "false" || adminScopes.platformOwner) { await hasPermission('project', 'add'); @@ -277,7 +278,6 @@ export const addProject = async ( ); } } - if (input.name.trim().length == 0) { throw new Error( 'A project name must be provided!' @@ -1098,3 +1098,18 @@ export const updateProjectMetadata: ResolverFn = async ( return Helpers(sqlClientPool).getProjectById(id); }; + + +/* + getFeatureApiRoutes is a field resolver + it has no permission checks as it isn't called directly + this is used to set the beta feature flag on a project from the organization + this feature will eventually be made generally available and the feature flag will be removed +*/ +export const getFeatureApiRoutes: ResolverFn = async ( + input, + args, + { sqlClientPool } +) => { + return Helpers(sqlClientPool).checkApiRoutesFeature(input.organization); +}; diff --git a/services/api/src/resources/routes/helpers.ts b/services/api/src/resources/routes/helpers.ts new file mode 100644 index 0000000000..78a07f27a2 --- /dev/null +++ b/services/api/src/resources/routes/helpers.ts @@ -0,0 +1,266 @@ +import { Pool } from 'mariadb'; +import { query } from '../../util/db'; +import { Sql } from './sql'; +import { isDNS1123Subdomain } from '../../util/func'; +import { logger } from '../../loggers/logger'; + +export const AnnotationLimit = 10; +export const PathRoutesLimit = 10; +export const AlternativeDomainsLimit = 25; +export const AutogeneratedPathRoutesLimit = 10; + +export const Helpers = (sqlClientPool: Pool) => { + const removeAllRoutesFromEnvironment = async (environmentId: number) => { + await query( + sqlClientPool, + Sql.removeAllRoutesFromEnvironment(environmentId) + ) + } + const deleteAutogeneratedRoutesForEnvironment = async (environmentId: number) => { + await query( + sqlClientPool, + Sql.deleteAutogeneratedRoutesForEnvironment(environmentId) + ) + } + const deleteLagoonYAMLRoutesForEnvironment = async (environmentId: number) => { + await query( + sqlClientPool, + Sql.deleteLagoonYAMLRoutesForEnvironment(environmentId) + ) + } + const deleteAutogeneratedRouteConfigForEnvironment = async (environmentId: number) => { + await query( + sqlClientPool, + Sql.deleteAutogeneratedRouteConfigForEnvironment(environmentId) + ) + } + const deleteAutogeneratedRouteConfigForProject = async (environmentId: number) => { + await query( + sqlClientPool, + Sql.deleteAutogeneratedRouteConfigForProject(environmentId) + ) + } + const removeRouteFromEnvironment = async (domain: string, environmentId: number) => { + const routes = await query( + sqlClientPool, + Sql.selectRoutesByDomainAndEnvironmentID(domain, environmentId) + ) + if (routes.length == 0) { + throw new Error(`Route doesn't exist on this environment`); + } + const route = routes[0] + await query( + sqlClientPool, + Sql.removeRouteFromEnvironment(route.id) + ) + return route.id + }; + const checkAnnotationRequirements = async (routeId: number, annotations: RouteAnnotations) => { + for(const annotation of annotations) { + const existingAnnotations = await query(sqlClientPool, Sql.selectRouteAnnotationsByRouteID(routeId)) + const exists = existingAnnotations.some( + (a) => a.key === annotation.key && a.value === annotation.value + ); + if (exists) { + throw new Error(`Annotation already exists on route`); + } else { + const combinedAnnotationCount = existingAnnotations.length + 1 + // arbitrary limit of 10 annotations, maybe this should be less? + // would prefer that annotations done this way weren't a thing, but here we are + if (combinedAnnotationCount >= AnnotationLimit) { + throw Error(`Limit of ${AnnotationLimit} annotations per route`) + } + } + } + }; + const checkDuplicateAnnotations = async (annotations: RouteAnnotations) => { + const normalized = annotations.map(item => item.key.trim().toLowerCase()); + if (new Set(normalized).size !== normalized.length) { + throw Error(`Duplicate annotation keys provided in annotations`) + } + if (annotations.length >= AnnotationLimit) { + throw Error(`Limit of ${AnnotationLimit} annotations per route`) + } + }; + const checkAlternativeNamesRequirements = async (alternativeNames: string[], projectId: number, routeId?: number) => { + // check if the limit is not exceeded + let existingNames = 0 + if (routeId) { + const existingAltNames = await query( + sqlClientPool, + Sql.selectRouteAlternativeDomainsByRouteID(routeId) + ) + existingNames = existingAltNames.length + } + const combinedAltDomainCount = existingNames + alternativeNames.length + if (combinedAltDomainCount >= AlternativeDomainsLimit) { + throw Error(`Limit of ${AlternativeDomainsLimit} alternative domains, consider removing some from this route, or create a new route`) + } + // if not exceeded, validate and check if not already exists + for (const d of alternativeNames) { + // check if the domain is valid dns subdomain + if (!isDNS1123Subdomain(d)) { + throw Error(`'${d}' is not a valid domain`) + } + const exists = await query( + sqlClientPool, + Sql.selectRouteByDomainAndProjectID(d, projectId) + ) + const exists2 = await query( + sqlClientPool, + Sql.selectRouteAlternativeDomainsByDomainAndProjectID(d, projectId) + ) + // if the domains provided don't already exist, then add them + if (exists.length > 0 || exists2.length > 0) { + throw Error(`Route already exists in this project`) + } + } + } + const checkDuplicateAlternativeNames = async (domainName: string, alternativeNames: string[]) => { + for (const d of alternativeNames) { + // trim spaces from the alternate domain as part of comparison check + const altDomain = d.trim() + if (altDomain == domainName) { + throw Error(`Main domain included in alternate domains`) + } + } + if (hasDuplicates(alternativeNames)) { + throw Error(`Duplicate domains provided in alternate domains`) + } + }; + const addRouteAnnotations = async (routeId: number, annotations: RouteAnnotations) => { + for(const annotation of annotations) { + await query( + sqlClientPool, + Sql.insertRouteAnnotation({routeId, key: annotation.key, value: annotation.value}) + ) + } + } + const deleteRouteAnnotation = async (routeId: number, key: string) => { + await query( + sqlClientPool, + Sql.deleteRoutesAnnotation(routeId, key) + ) + } + const deleteRouteAnnotations = async (routeId: number) => { + await query( + sqlClientPool, + Sql.deleteRoutesAnnotations(routeId) + ) + } + + return { + removeAllRoutesFromEnvironment, + deleteAutogeneratedRoutesForEnvironment, + deleteLagoonYAMLRoutesForEnvironment, + removeRouteFromEnvironment, + addRouteAnnotations, + checkAnnotationRequirements, + checkDuplicateAnnotations, + checkAlternativeNamesRequirements, + checkDuplicateAlternativeNames, + deleteRouteAnnotation, + deleteRouteAnnotations, + deleteAutogeneratedRouteConfigForEnvironment, + deleteAutogeneratedRouteConfigForProject, + } +} + +export function addServicePathRoute( + pathRoutes: PathRoutes, + newToService: string, + newPath: string +): PathRoutes { + const exists = pathRoutes.some( + (a) => a.toService === newToService && a.path === newPath + ); + if (!exists) { + return [...pathRoutes, { toService: newToService, path: newPath }]; + } + + return pathRoutes; +} + +export function checkServicePathRouteRequirements( + existingPathRoutes: PathRoutes, + pathRoutes: PathRoutes, +) { + const combinedPathRoutesCount = pathRoutes.length + existingPathRoutes.length + if (combinedPathRoutesCount > PathRoutesLimit) { + throw Error(`Limit of ${PathRoutesLimit} path routes, consider removing some from this route`) + } +} + +export function checkServiceAutoGenPathRouteRequirements( + pathRoutes: AutoGenPathRoutes, +) { + if (pathRoutes.length > AutogeneratedPathRoutesLimit) { + throw Error(`Limit of ${AutogeneratedPathRoutesLimit} path routes for autogenerated routes`) + } +} + +export type RouteAnnotation = { + key: string; + value: string; +}; + +export type RouteAnnotations = RouteAnnotation[]; + +export type PathRoute = { + toService: string; + path: string; +}; + +export type PathRoutes = PathRoute[]; + +export type AutoGenPathRoute = { + fromService: string; + toService: string; + path: string; +} + +export type AutoGenPathRoutes = AutoGenPathRoute[]; + +export function removeServicePathRoute( + pathRoutes: PathRoutes, + targetToService: string, + targetPath: string +): PathRoutes { + return pathRoutes.filter( + (a) => !(a.toService === targetToService && a.path === targetPath) + ); +} + +export function addAutogenServicePathRoute( + pathRoutes: AutoGenPathRoutes, + newFromService: string, + newToService: string, + newPath: string +): AutoGenPathRoutes { + const exists = pathRoutes.some( + (a) => a.toService === newToService && a.path === newPath + ); + + if (!exists) { + return [...pathRoutes, { fromService: newFromService, toService: newToService, path: newPath }]; + } + + return pathRoutes; +} + +export function removeAutogenServicePathRoute( + pathRoutes: AutoGenPathRoutes, + targetFromService: string, + targetToService: string, + targetPath: string +): AutoGenPathRoutes { + return pathRoutes.filter( + (a) => !(a.fromService === targetFromService &&a.toService === targetToService && a.path === targetPath) + ); +} + +// check array for duplicates that are trimmed and lowercased +function hasDuplicates(arr) { + const normalized = arr.map(item => item.trim().toLowerCase()); + return new Set(normalized).size !== normalized.length; +} \ No newline at end of file diff --git a/services/api/src/resources/routes/resolvers.ts b/services/api/src/resources/routes/resolvers.ts new file mode 100644 index 0000000000..bf067b5be3 --- /dev/null +++ b/services/api/src/resources/routes/resolvers.ts @@ -0,0 +1,1622 @@ +// @ts-ignore +import * as R from 'ramda'; + +import { ResolverFn } from '../'; +import { knex, query } from '../../util/db'; +import { Sql } from './sql'; +import { Helpers as environmentHelpers } from '../environment/helpers'; +import { Helpers as projectHelpers } from '../project/helpers'; +import { addServicePathRoute, removeServicePathRoute, Helpers, PathRoutes, checkServicePathRouteRequirements, checkServiceAutoGenPathRouteRequirements } from './helpers'; +import { AuditLog } from '../audit/types'; +import { isDNS1123Subdomain } from '../../util/func'; +import { AuditType, RouteSource, RouteType } from '@lagoon/commons/dist/types'; + +/* + addRouteToProject is used to add a route to a project + the route can be attached to an environment and service at creation time if necessary + if attaching at creation time, both environment and service must be defined + primary route and type can only be assigned when it is being attached to an environment +*/ +export const addRouteToProject: ResolverFn = async ( + root, + { + input: { + id, + domain, + alternativeNames, + annotations, + pathRoutes, + environment, + project, + service, + primary, + type, + source, + tlsAcme, + insecure, + hstsEnabled, + hstsPreload, + hstsIncludeSubdomains, + hstsMaxAge, + monitoringPath, + disableRequestVerification, + } + }, + { sqlClientPool, hasPermission, userActivityLogger, adminScopes } +) => { + const projectId = await projectHelpers(sqlClientPool).getProjectIdByName(project) + const projectData = await projectHelpers(sqlClientPool).getProjectById(projectId) + await hasPermission('route', 'add', { + project: projectId + }); + + // this is used to set the beta feature flag on a project from the organization + // this feature will eventually be made generally available and the feature flag will be removed + if (await projectHelpers(sqlClientPool).checkApiRoutesFeature(projectData.organization) === false) { + throw Error(`This feature is currently unavailable`) + } + + // trim spaces from domain name + const domainName = domain.trim() + + let environmentData; + let environmentId = null; + // let environmentServices; + if (environment) { + const env = await environmentHelpers(sqlClientPool).getEnvironmentByNameAndProject(environment, projectId) + environmentData = env[0] + environmentId = environmentData.id + // environmentServices = await environmentHelpers(sqlClientPool).getEnvironmentServices(environmentId) + } + + // check the route doesn't already exist in this project as either a top level route, or an alternative domain on another route + const exists = await query( + sqlClientPool, + Sql.selectRouteByDomainAndProjectID(domainName, projectId) + ) + const exists2 = await query( + sqlClientPool, + Sql.selectRouteAlternativeDomainsByDomainAndProjectID(domainName, projectId) + ) + + // fail if the route already exists somewhere in the project + if (exists.length > 0 || exists2.length > 0) { + throw Error(`Route already exists in this project`) + } + + // check if the domain is valid dns subdomain + if (!isDNS1123Subdomain(domainName)) { + throw Error(`'${domainName}' is not a valid domain`) + } + + if (environment && !service) { + throw Error(`Service is required when adding a domain linked to an environment`) + } + if (!environment && service) { + throw Error(`Environment is required when adding a domain linked to a service`) + } + // if (environment) { + // // ensure the service exists on the environment + // if (!environmentServices.some(item => item.name === service)) { + // // @TODO: maybe this should be a warning instead of a blocking operation + // // allow the route to be associated to a non-existing service, except + // // show a warning in the UI if the service doesn't exist + // throw Error(`Service ${service} doesn't exist on this environment`) + // } + // } + + // fail if the domain is provided more than once + if (alternativeNames !== undefined) { + await Helpers(sqlClientPool).checkDuplicateAlternativeNames(domainName, alternativeNames); + // check the route doesn't already exist in this project, and that any limits are not exceeded + await Helpers(sqlClientPool).checkAlternativeNamesRequirements(alternativeNames, projectId) + } + + // fail if duplicate annotations provided + if (annotations !== undefined) { + await Helpers(sqlClientPool).checkDuplicateAnnotations(annotations); + } + + // setup pathroutes if provided + let pr: PathRoutes = []; + if (environment) { + if (pathRoutes !== undefined) { + checkServicePathRouteRequirements(pr, pathRoutes) + for (const pathRoute of pathRoutes) { + // ensure the service exists on the environment + // if (!environmentServices.some(item => item.name === pathRoute.toService)) { + // // @TODO: maybe this should be a warning instead of a blocking operation + // // allow the route to be associated to a non-existing service, except + // // show a warning in the UI if the service doesn't exist + // throw Error(`Service ${pathRoute.toService} in pathRoutes doesn't exist on this environment`) + // } + pr = addServicePathRoute(pr, pathRoute.toService, pathRoute.path) + } + } + } + + // can only set type if assigning to an environment + if (type && !environment) { + type = RouteType.STANDARD + } else if (type && environment) { + // can only add active/standby type if the environment supports it + const isActive = type === RouteType.ACTIVE; + const isStandby = type === RouteType.STANDBY; + if ((isActive || isStandby) && !projectData.standbyProductionEnvironment) { + throw Error(`Can't add ${type} route to environment that isn't active or standby`); + } + if ((isActive && projectData.standbyProductionEnvironment === environmentData.name) || + (isStandby && projectData.productionEnvironment === environmentData.name)) { + throw Error(`Can't add ${type} route to ${isActive ? 'standby' : 'active'} environment`); + } + } + + if (!adminScopes.platformOwner) { + // prevent users from creating routes a source that isn't api + if (source && source !== RouteSource.API) { + throw Error(`Can only create routes with source API`); + } + } + + /* + WARNING: anything after this point makes changes to the route and how it is associated to a project or environment + */ + if (primary == true && environment) { + // check if another route isn't already the primary route, unset any other primary routes in this environment + await query(sqlClientPool, Sql.unsetEnvironmentPrimaryRoute(environmentData.id, projectId)); + } else { + // can only set primary on a route if environment is being provided + primary = false; + } + // add the domain + const { insertId } = await query( + sqlClientPool, + Sql.insertRoute({ + id, + domain: domainName, + project: projectId, + environment: environmentId, + service, + source, + pathRoutes: JSON.stringify(pr), + primary, + type, + tlsAcme, + insecure, + hstsEnabled, + hstsPreload, + hstsIncludeSubdomains, + hstsMaxAge, + monitoringPath, + disableRequestVerification + }) + ); + const rows = await query(sqlClientPool, Sql.selectRouteByID(insertId)); + const route = R.prop(0, rows); + + // setup route annotations if provided + if (annotations) { + await Helpers(sqlClientPool).addRouteAnnotations(route.id, annotations) + } + + // add the alternate domains + if (alternativeNames !== undefined) { + for (const d of alternativeNames) { + // trim spaces from domain name + const altDomain = d.trim() + await query( + sqlClientPool, + Sql.insertRouteAlternativeDomain({ + rid: insertId, + domain: altDomain, + }) + ); + } + } + + const auditLog: AuditLog = { + resource: { + id: projectData.id.toString(), + type: AuditType.PROJECT, + details: projectData.name, + }, + }; + if (environment) { + auditLog.linkedResource = { + id: environmentData.id.toString(), + type: AuditType.ENVIRONMENT, + details: environmentData.name + } + } + if (projectData.organization) { + auditLog.organizationId = projectData.organization; + } + userActivityLogger(`User added route '${route.domain}' to project '${projectData.name}'`, { + project: '', + event: 'api:addRouteToProject', + payload: { + project: projectData.id, + route: route.id, + ...auditLog + } + }); + + return route; +}; + +/* + updateRouteOnProject is used to update a route, only certain options can be updated using this endpoint + it isn't possible to rename a domain after creation, a new one should be created and the old one deleted +*/ +export const updateRouteOnProject: ResolverFn = async ( + root, + { + input: { + domain, + project, + patch + } + }, + { sqlClientPool, hasPermission, userActivityLogger, adminScopes } +) => { + const projectData = await projectHelpers(sqlClientPool).getProjectByName(project) + const projectId = projectData.id + await hasPermission('route', 'update', { + project: projectId + }); + + // this is used to set the beta feature flag on a project from the organization + // this feature will eventually be made generally available and the feature flag will be removed + if (await projectHelpers(sqlClientPool).checkApiRoutesFeature(projectData.organization) === false) { + throw Error(`This feature is currently unavailable`) + } + + const existsProject = await query( + sqlClientPool, + Sql.selectRouteByDomainAndProjectID(domain, projectId) + ) + if (existsProject.length == 0) { + throw Error(`Route doesn't exist on this project`) + } + const route = existsProject[0] + + if (!adminScopes.platformOwner) { + // prevent changing route type by general users except for yaml>api ownership + switch (route.source) { + case RouteSource.YAML: + if (patch.source && patch.source == RouteSource.API) { + // if the route is being updated to be sourced from the API + // allow it + break; + } + // otherwise reject the update + throw Error(`Cannot update route managed by lagoon.yml`) + case RouteSource.AUTOGENERATED: + // reject update from general users + throw Error(`Cannot update autogenerated routes`) + default: + break; + } + } + + // set the updated timestamp on the patch + patch.updated = knex.fn.now() + + /* + WARNING: anything after this point makes changes to the route + */ + await query( + sqlClientPool, + Sql.updateRoute({ + id: route.id, + patch: patch, + }) + ); + const auditLog: AuditLog = { + resource: { + id: projectData.id.toString(), + type: AuditType.PROJECT, + details: projectData.name, + }, + }; + if (projectData.organization) { + auditLog.organizationId = projectData.organization; + } + userActivityLogger(`User updated route '${route.domain}' on project '${projectData.name}'`, { + project: '', + event: 'api:updateRouteOnProject', + payload: { + project: projectData.id, + route: route.id, + ...auditLog + } + }); + + const ret = await query(sqlClientPool, Sql.selectRouteByID(route.id)); + return ret[0]; +} + +/* + addOrUpdateRouteOnEnvironment is used to attach a route to an environment + if it wasn't already attached to one when the route was created. + it can also be used to change which environment it is attached to + in cases like this, if it is moved from one environment to another + then it is recommended tht the environment it was removed from be redeployed before the one it was attached to + this is to prevent collision issues in kubernetes +*/ +export const addOrUpdateRouteOnEnvironment: ResolverFn = async ( + root, + { + input: { + domain, + pathRoutes, + project, + environment, + service, + primary, + type, + } + }, + { sqlClientPool, hasPermission, userActivityLogger } +) => { + const projectData = await projectHelpers(sqlClientPool).getProjectByName(project) + const projectId = projectData.id + await hasPermission('route', 'add:environment', { + project: projectId + }); + + // this is used to set the beta feature flag on a project from the organization + // this feature will eventually be made generally available and the feature flag will be removed + if (await projectHelpers(sqlClientPool).checkApiRoutesFeature(projectData.organization) === false) { + throw Error(`This feature is currently unavailable`) + } + + const env = await environmentHelpers(sqlClientPool).getEnvironmentByNameAndProject(environment, projectId) + const environmentData = env[0] + + const existsProject = await query( + sqlClientPool, + Sql.selectRouteByDomainAndProjectID(domain, projectId) + ) + if (existsProject.length == 0) { + throw Error(`Route doesn't exist on this project`) + } + const route = existsProject[0] + + if (primary == true) { + // check if another route isn't already the primary route, unset any other primary routes in this environment + await query(sqlClientPool, Sql.unsetEnvironmentPrimaryRoute(environmentData.id, route.project)); + } + + if (type) { + // can only add active/standby type if the environment supports it + const isActive = type === RouteType.ACTIVE; + const isStandby = type === RouteType.STANDBY; + if ((isActive || isStandby) && !projectData.standbyProductionEnvironment) { + throw Error(`Can't add ${type} route to environment that isn't active or standby`); + } + if ((isActive && projectData.standbyProductionEnvironment === environmentData.name) || + (isStandby && projectData.productionEnvironment === environmentData.name)) { + throw Error(`Can't add ${type} route to ${isActive ? 'standby' : 'active'} environment`); + } + } + + switch (route.environment) { + case environmentData.id: + case 0: + case null: + // do nothing + break; + default: + throw Error(`Route is already attached to another environment`) + } + + // ensure the service exists on the environment + // const environmentServices = await environmentHelpers(sqlClientPool).getEnvironmentServices(environmentData.id) + // if (!environmentServices.some(item => item.name === service)) { + // // @TODO: maybe this should be a warning instead of a blocking operation + // // allow the route to be associated to a non-existing service, except + // // show a warning in the UI if the service doesn't exist + // throw Error(`Service ${service} doesn't exist on this environment`) + // } + + // setup pathroutes if provided + let pr: PathRoutes = []; + if (environment) { + if (pathRoutes !== undefined) { + checkServicePathRouteRequirements(JSON.parse(route.pathRoutes), pathRoutes) + for (const pathRoute of pathRoutes) { + // ensure the service exists on the environment + // if (!environmentServices.some(item => item.name === pathRoute.toService)) { + // // @TODO: maybe this should be a warning instead of a blocking operation + // // allow the route to be associated to a non-existing service, except + // // show a warning in the UI if the service doesn't exist + // throw Error(`Service ${pathRoute.toService} in pathRoutes doesn't exist on this environment`) + // } + pr = addServicePathRoute(pr, pathRoute.toService, pathRoute.path) + } + } + } + + /* + WARNING: anything after this point makes changes to the route + */ + await query( + sqlClientPool, + Sql.updateRoute({ + id: route.id, + patch: { + environment: environmentData.id, + service, + pathRoutes: JSON.stringify(pr), + updated: knex.fn.now(), + primary, + type, + } + }) + ); + + const auditLog: AuditLog = { + resource: { + id: projectData.id.toString(), + type: AuditType.PROJECT, + details: projectData.name, + }, + }; + if (environment) { + auditLog.linkedResource = { + id: environmentData.id.toString(), + type: AuditType.ENVIRONMENT, + details: environmentData.name + } + } + if (projectData.organization) { + auditLog.organizationId = projectData.organization; + } + userActivityLogger(`User added route '${route.domain}' to environment '${environmentData.name}'`, { + project: '', + event: 'api:addOrUpdateRouteOnEnvironment', + payload: { + project: projectData.id, + environment: environmentData.id, + route: route.id, + ...auditLog + } + }); + + const ret = await query(sqlClientPool, Sql.selectRouteByID(route.id)); + return ret[0]; +} + +/* + activeStandbyRouteMove is used by the activestandby task completion + to update the api with which environment the route was moved to as part of the process +*/ +export const activeStandbyRouteMove: ResolverFn = async ( + root, + { + input: { + domain, + project, + environment, + service, + type, + }, + }, + { sqlClientPool, hasPermission, userActivityLogger, adminScopes} +) => { + if (adminScopes.platformOwner) { + const projectId = await projectHelpers(sqlClientPool).getProjectIdByName(project) + const env = await environmentHelpers(sqlClientPool).getEnvironmentByNameAndProject(environment, projectId) + const environmentData = env[0] + + const existsProject = await query( + sqlClientPool, + Sql.selectRouteByDomainAndProjectID(domain, projectId) + ) + if (existsProject.length == 0) { + throw Error(`Route doesn't exist on this project`) + } + const route = existsProject[0] + + // ensure the service exists on the environment + // const environmentServices = await environmentHelpers(sqlClientPool).getEnvironmentServices(environmentData.id) + // if (!environmentServices.some(item => item.name === service)) { + // // @TODO: maybe this should be a warning instead of a blocking operation + // // allow the route to be associated to a non-existing service, except + // // show a warning in the UI if the service doesn't exist + // throw Error(`Service ${service} doesn't exist on this environment`) + // } + + await query( + sqlClientPool, + Sql.updateRoute({ + id: route.id, + patch: { + environment: environmentData.id, + service, + type, + } + }) + ); + + userActivityLogger(`User moved route '${route.domain}' to environment '${environmentData.name}'`, { + project: '', + event: 'api:activeStandbyRouteMove', + payload: { + project: projectId, + environment: environmentData.id, + route: route.id + } + }); + + const ret = await query(sqlClientPool, Sql.selectRouteByID(route.id)); + return ret[0]; + } else { + // throw unauthorized error + throw new Error( + `Unauthorized` + ); + } +} + +/* + removeRouteFromEnvironment is used to remove a route from an environment + this leaves the route intact, just no longer associated to an environment + when it is removed from an environment, some settings are reverted to their defaults + * `primary` reverted to `false` + * `type` reverted to `STANDARD` + * `pathRoutes` nullified + any other settings on the route remain untouched +*/ +export const removeRouteFromEnvironment: ResolverFn = async ( + root, + { + input: { + domain, + project, + environment, + } + }, + { sqlClientPool, hasPermission, userActivityLogger, adminScopes } +) => { + const projectId = await projectHelpers(sqlClientPool).getProjectIdByName(project) + const projectData = await projectHelpers(sqlClientPool).getProjectById(projectId) + await hasPermission('route', 'remove:environment', { + project: projectId + }); + + // this is used to set the beta feature flag on a project from the organization + // this feature will eventually be made generally available and the feature flag will be removed + if (await projectHelpers(sqlClientPool).checkApiRoutesFeature(projectData.organization) === false) { + throw Error(`This feature is currently unavailable`) + } + + const env = await environmentHelpers(sqlClientPool).getEnvironmentByNameAndProject(environment, projectId) + const environmentData = env[0] + + const existsProject = await query( + sqlClientPool, + Sql.selectRouteByDomainAndProjectID(domain, projectId) + ) + if (existsProject.length == 0) { + throw Error(`Route doesn't exist on this project`) + } + const route = existsProject[0] + + if (!adminScopes.platformOwner) { + if (route.source.toLowerCase() === RouteSource.YAML) { + throw Error(`This route cannot be removed from the environment as it is managed by a lagoon.yml file`) + } + if (route.source.toLowerCase() === RouteSource.AUTOGENERATED) { + throw Error(`Cannot remove autogenerated routes from an environment using this endpoint`) + } + } + + /* + WARNING: anything after this point makes changes to the route + */ + const routeId = await Helpers(sqlClientPool).removeRouteFromEnvironment(domain, environmentData.id) + + const ret = await query(sqlClientPool, Sql.selectRouteByID(routeId)); + const returnRoute = ret[0]; + + const auditLog: AuditLog = { + resource: { + id: projectData.id.toString(), + type: AuditType.PROJECT, + details: projectData.name, + }, + }; + if (environment) { + auditLog.linkedResource = { + id: environmentData.id.toString(), + type: AuditType.ENVIRONMENT, + details: environmentData.name + } + } + if (projectData.organization) { + auditLog.organizationId = projectData.organization; + } + userActivityLogger(`User removed route '${returnRoute.domain}' from environment '${environmentData.name}'`, { + project: '', + event: 'api:removeRouteFromEnvironment', + payload: { + project: projectData.id, + environment: environmentData.id, + route: returnRoute.id, + ...auditLog + } + }); + + return returnRoute; +} + +/* + addRouteAlternativeDomains can be used to extend an existing route with any subject alternative domains + this will put all the routes in this list onto the one resource when it is deployed in kubernetes + 1 ingress with many hosts, instead of 1 ingress per host. + if using tlsAcme, all hosts will be on the one certificate +*/ +export const addRouteAlternativeDomains: ResolverFn = async ( + root, + { + input: { + id, + alternativeNames, + } + }, + { sqlClientPool, hasPermission, userActivityLogger } +) => { + const rows = await query(sqlClientPool, Sql.selectRouteByID(id)); + if (rows.length == 0) { + throw new Error(`Unauthorized: You don't have permission to "add" on "route"`); + } + const route = R.prop(0, rows); + const projectData = await projectHelpers(sqlClientPool).getProjectById(route.project) + await hasPermission('route', 'add', { + project: projectData.id + }); + + // this is used to set the beta feature flag on a project from the organization + // this feature will eventually be made generally available and the feature flag will be removed + if (await projectHelpers(sqlClientPool).checkApiRoutesFeature(projectData.organization) === false) { + throw Error(`This feature is currently unavailable`) + } + + if (alternativeNames !== undefined) { + await Helpers(sqlClientPool).checkDuplicateAlternativeNames(route.domain, alternativeNames) + // check the route doesn't already exist in this project, and that any limits are not exceeded + await Helpers(sqlClientPool).checkAlternativeNamesRequirements(alternativeNames, route.project) + } + + /* + WARNING: anything after this point makes changes to the route + */ + // add the alternate domains + if (alternativeNames !== undefined) { + for (const d of alternativeNames) { + await query( + sqlClientPool, + Sql.insertRouteAlternativeDomain({ + rid: id, + domain: d, + }) + ); + } + } + + const auditLog: AuditLog = { + resource: { + id: projectData.id.toString(), + type: AuditType.PROJECT, + details: projectData.name, + }, + }; + if (projectData.organization) { + auditLog.organizationId = projectData.organization; + } + userActivityLogger(`User added alternative domains to route '${route.domain}' on project '${projectData.name}'`, { + project: '', + event: 'api:addRouteAlternativeDomains', + payload: { + project: projectData.id, + route: route.id, + ...auditLog + } + }); + + return route; +} + +/* + removeRouteAlternativeDomain will remove an alternative domain from an existing route +*/ +export const removeRouteAlternativeDomain: ResolverFn = async ( + root, + { + input: { + id, + domain, + } + }, + { sqlClientPool, hasPermission, userActivityLogger } +) => { + const rows = await query(sqlClientPool, Sql.selectRouteByID(id)); + if (rows.length == 0) { + throw new Error(`Unauthorized: You don't have permission to "add" on "route"`); + } + const route = R.prop(0, rows); + const projectData = await projectHelpers(sqlClientPool).getProjectById(route.project) + await hasPermission('route', 'add', { + project: projectData.id + }); + + // this is used to set the beta feature flag on a project from the organization + // this feature will eventually be made generally available and the feature flag will be removed + if (await projectHelpers(sqlClientPool).checkApiRoutesFeature(projectData.organization) === false) { + throw Error(`This feature is currently unavailable`) + } + + const alternateDomain = await query( + sqlClientPool, + Sql.selectRouteAlternativeDomainsByDomainAndProjectID(domain, route.project) + ) + + /* + WARNING: anything after this point makes changes to the route + */ + if (alternateDomain.length > 0) { + const ad = R.prop(0, alternateDomain); + await query(sqlClientPool, Sql.deleteRouteAlternativeDomain(ad.id)); + } else { + throw Error(`Domain doesn't exist on this route`) + } + + const auditLog: AuditLog = { + resource: { + id: projectData.id.toString(), + type: AuditType.PROJECT, + details: projectData.name, + }, + }; + if (projectData.organization) { + auditLog.organizationId = projectData.organization; + } + userActivityLogger(`User removed alternative domains from route '${route.domain}' on project '${projectData.name}'`, { + project: '', + event: 'api:removeRouteAlternativeDomain', + payload: { + project: projectData.id, + route: route.id, + ...auditLog + } + }); + + return route; +} + +/* + addRouteAnnotation allows for annotations to be added to a route + no enforcement is made here, except the build may asses and reject any annotations that + don't meet specific criteria + ideally we wouldn't allow raw annotations to be added + and enforcement/restrictions may apply in the future +*/ +export const addRouteAnnotation: ResolverFn = async ( + root, + { + input: { + id, + annotations, + } + }, + { sqlClientPool, hasPermission, userActivityLogger } +) => { + const rows = await query(sqlClientPool, Sql.selectRouteByID(id)); + if (rows.length == 0) { + throw new Error(`Unauthorized: You don't have permission to "add" on "route"`); + } + const route = R.prop(0, rows); + const projectData = await projectHelpers(sqlClientPool).getProjectById(route.project) + await hasPermission('route', 'add', { + project: projectData.id + }); + + // this is used to set the beta feature flag on a project from the organization + // this feature will eventually be made generally available and the feature flag will be removed + if (await projectHelpers(sqlClientPool).checkApiRoutesFeature(projectData.organization) === false) { + throw Error(`This feature is currently unavailable`) + } + + // fail if duplicate annotations provided + await Helpers(sqlClientPool).checkDuplicateAnnotations(annotations); + // check annotation requirements and fail if required + await Helpers(sqlClientPool).checkAnnotationRequirements(route.id, annotations); + + /* + WARNING: anything after this point makes changes to the route + */ + await Helpers(sqlClientPool).addRouteAnnotations(route.id, annotations) + + await query( + sqlClientPool, + Sql.updateRoute({ + id: id, + patch: { + updated: knex.fn.now(), + } + }) + ); + + const ret = await query(sqlClientPool, Sql.selectRouteByID(id)); + const retRoute = R.prop(0, ret); + + const auditLog: AuditLog = { + resource: { + id: projectData.id.toString(), + type: AuditType.PROJECT, + details: projectData.name, + }, + }; + if (projectData.organization) { + auditLog.organizationId = projectData.organization; + } + userActivityLogger(`User added annotations to route '${route.domain}' on project '${projectData.name}'`, { + project: '', + event: 'api:addRouteAnnotation', + payload: { + project: projectData.id, + route: route.id, + ...auditLog + } + }); + + return retRoute; +} + +/* + removeRouteAnnotation will remove an annotation from a route +*/ +export const removeRouteAnnotation: ResolverFn = async ( + root, + { + input: { + id, + key, + } + }, + { sqlClientPool, hasPermission, userActivityLogger } +) => { + const rows = await query(sqlClientPool, Sql.selectRouteByID(id)); + if (rows.length == 0) { + throw new Error(`Unauthorized: You don't have permission to "add" on "route"`); + } + const route = R.prop(0, rows); + const projectData = await projectHelpers(sqlClientPool).getProjectById(route.project) + await hasPermission('route', 'add', { + project: projectData.id + }); + + // this is used to set the beta feature flag on a project from the organization + // this feature will eventually be made generally available and the feature flag will be removed + if (await projectHelpers(sqlClientPool).checkApiRoutesFeature(projectData.organization) === false) { + throw Error(`This feature is currently unavailable`) + } + + /* + WARNING: anything after this point makes changes to the route + */ + await Helpers(sqlClientPool).deleteRouteAnnotation(route.id, key) + + await query( + sqlClientPool, + Sql.updateRoute({ + id: id, + patch: { + updated: knex.fn.now(), + } + }) + ); + + const ret = await query(sqlClientPool, Sql.selectRouteByID(id)); + const retRoute = R.prop(0, ret); + + const auditLog: AuditLog = { + resource: { + id: projectData.id.toString(), + type: AuditType.PROJECT, + details: projectData.name, + }, + }; + if (projectData.organization) { + auditLog.organizationId = projectData.organization; + } + userActivityLogger(`User removed annotations from route '${route.domain}' on project '${projectData.name}'`, { + project: '', + event: 'api:removeRouteAnnotation', + payload: { + project: projectData.id, + route: route.id, + ...auditLog + } + }); + + return retRoute; +} + +/* + addPathRoutesToRoute is a way to extend a route with the `pathRoutes` feature + this allows certain paths on a route to point to a different service to serve that traffic +*/ +export const addPathRoutesToRoute: ResolverFn = async ( + root, + { + input: { + id, + pathRoutes, + } + }, + { sqlClientPool, hasPermission, userActivityLogger } +) => { + const rows = await query(sqlClientPool, Sql.selectRouteByID(id)); + if (rows.length == 0) { + throw new Error(`Unauthorized: You don't have permission to "add" on "route"`); + } + const route = rows[0]; + const projectData = await projectHelpers(sqlClientPool).getProjectById(route.project) + await hasPermission('route', 'add', { + project: projectData.id + }); + + // this is used to set the beta feature flag on a project from the organization + // this feature will eventually be made generally available and the feature flag will be removed + if (await projectHelpers(sqlClientPool).checkApiRoutesFeature(projectData.organization) === false) { + throw Error(`This feature is currently unavailable`) + } + + let pr: PathRoutes = []; + + pr = JSON.parse(route.pathRoutes) + + if (pathRoutes !== undefined) { + checkServicePathRouteRequirements(pr, pathRoutes) + for (const pathRoute of pathRoutes) { + pr = addServicePathRoute(pr, pathRoute.toService, pathRoute.path) + } + } + route.pathRoutes = JSON.stringify(pr) + + let patch = { + pathRoutes: JSON.stringify(pr), + updated: knex.fn.now(), + } + + /* + WARNING: anything after this point makes changes to the route + */ + await query( + sqlClientPool, + Sql.updateRoute({ + id: id, + patch: patch + }) + ); + + const auditLog: AuditLog = { + resource: { + id: projectData.id.toString(), + type: AuditType.PROJECT, + details: projectData.name, + }, + }; + if (projectData.organization) { + auditLog.organizationId = projectData.organization; + } + userActivityLogger(`User added pathroutes to route '${route.domain}' on project ${projectData.name}`, { + project: '', + event: 'api:addPathRoutesToRoute', + payload: { + project: projectData.id, + route: route.id, + ...auditLog + } + }); + + const ret = await query(sqlClientPool, Sql.selectRouteByID(id)); + const retRoute = ret[0]; + + return retRoute; +} + +/* + removePathRouteFromRoute will remove a path route +*/ +export const removePathRouteFromRoute: ResolverFn = async ( + root, + { + input: { + id, + toService, + path + } + }, + { sqlClientPool, hasPermission, userActivityLogger } +) => { + const rows = await query(sqlClientPool, Sql.selectRouteByID(id)); + if (rows.length == 0) { + throw new Error(`Unauthorized: You don't have permission to "add" on "route"`); + } + const route = R.prop(0, rows); + const projectData = await projectHelpers(sqlClientPool).getProjectById(route.project) + await hasPermission('route', 'add', { + project: projectData.id + }); + + // this is used to set the beta feature flag on a project from the organization + // this feature will eventually be made generally available and the feature flag will be removed + if (await projectHelpers(sqlClientPool).checkApiRoutesFeature(projectData.organization) === false) { + throw Error(`This feature is currently unavailable`) + } + + let pr: PathRoutes = []; + + pr = JSON.parse(route.pathRoutes) + + pr = removeServicePathRoute(pr, toService, path) + route.pathRoutes = JSON.stringify(pr) + + let patch = { + pathRoutes: JSON.stringify(pr), + updated: knex.fn.now(), + } + + /* + WARNING: anything after this point makes changes to the route + */ + await query( + sqlClientPool, + Sql.updateRoute({ + id: id, + patch: patch + }) + ); + + const auditLog: AuditLog = { + resource: { + id: projectData.id.toString(), + type: AuditType.PROJECT, + details: projectData.name, + }, + }; + if (projectData.organization) { + auditLog.organizationId = projectData.organization; + } + userActivityLogger(`User removed pathroutes from route '${route.domain}' on project '${projectData.name}'`, { + project: '', + event: 'api:removePathRouteFromRoute', + payload: { + project: projectData.id, + route: route.id, + ...auditLog + } + }); + + const ret = await query(sqlClientPool, Sql.selectRouteByID(id)); + const retRoute = R.prop(0, ret); + + return retRoute; +} + +/* + deleteRoute does what it says on the tin, will delete a route from a project +*/ +export const deleteRoute: ResolverFn = async ( + root, + { input: { id } }, + { sqlClientPool, hasPermission, userActivityLogger, adminScopes } +) => { + const rows = await query(sqlClientPool, Sql.selectRouteByID(id)); + if (rows.length == 0) { + throw new Error(`Unauthorized: You don't have permission to "delete" on "route"`); + } + const route = rows[0] + const projectData = await projectHelpers(sqlClientPool).getProjectById(route.project) + await hasPermission('route', 'delete', { + project: projectData.id + }); + + // this is used to set the beta feature flag on a project from the organization + // this feature will eventually be made generally available and the feature flag will be removed + if (await projectHelpers(sqlClientPool).checkApiRoutesFeature(projectData.organization) === false) { + throw Error(`This feature is currently unavailable`) + } + + if (!adminScopes.platformOwner) { + // general users can't delete routes with these sources from the api + if (route.source.toLowerCase() == RouteSource.AUTOGENERATED) { + throw new Error(`Cannot delete autogenerated routes, you must modify the project or environment autogenerated route configuration`); + } + if (route.source.toLowerCase() == RouteSource.YAML) { + throw new Error(`Cannot delete routes that are managed by a lagoon.yml file, either modify the route in the api or delete it from the lagoon.yml file.`); + } + } + // @TODO: do we want to block deletion of routes if they are attached to an environment? + // if (route.environment) { + // throw Error(`Route must be removed from environment before deletion`) + // } + + await query(sqlClientPool, Sql.deleteRoutesAnnotations(route.id)); + await query(sqlClientPool, Sql.deleteRoutesAlternativeDomains(route.id)); + await query(sqlClientPool, Sql.deleteRoute(route.id)); + + const auditLog: AuditLog = { + resource: { + id: projectData.id.toString(), + type: AuditType.PROJECT, + details: projectData.name, + }, + }; + if (projectData.organization) { + auditLog.organizationId = projectData.organization; + } + userActivityLogger(`User deleted route '${route.domain}' and any associated alternate domains from project '${projectData.name}'`, { + project: '', + event: 'api:deleteRoute', + payload: { + route: route.id, + ...auditLog + } + }); + + return 'success'; +}; + +/* + getRoutesByProjectId is used to query the routes attached to a project +*/ +export const getRoutesByProjectId: ResolverFn = async ( + { id: projectId }, + { domain }, + { sqlClientPool, hasPermission } +) => { + await hasPermission('route', 'view', { + project: projectId + }); + + let queryBuilder = knex('routes') + .where('project', projectId) + .orderBy('id', 'desc'); + + if (domain) { + queryBuilder = queryBuilder.andWhere('domain', domain); + } + + return query(sqlClientPool, queryBuilder.toString()); +}; + +/* + getRoutesByProjectId is used to query the routes attached to an environment +*/ +export const getRoutesByEnvironmentId: ResolverFn = async ( + { id: environmentId }, + { domain, source }, + { sqlClientPool, hasPermission } +) => { + const { id: projectId } = await projectHelpers(sqlClientPool).getProjectByEnvironmentId(environmentId); + await hasPermission('route', 'view', { + project: projectId + }); + + let queryBuilder = knex('routes') + .where('environment', environmentId) + .orderBy('id', 'desc'); + + if (domain) { + queryBuilder = queryBuilder.andWhere('domain', domain); + } + + if (source) { + queryBuilder = queryBuilder.andWhere('source', source) + } + + return query(sqlClientPool, queryBuilder.toString()); +}; + +/* + getAlternateRoutesByRouteId is a field resolver + it has no permission checks as it isn't called directly +*/ +export const getAlternateRoutesByRouteId: ResolverFn = async ( + { id: rid }, + args, + { sqlClientPool, } +) => { + const rows = await query( + sqlClientPool, + Sql.selectRouteAlternativeDomainsByRouteID(rid) + ); + + return rows; +}; + +/* + getRouteAnnotationsByRouteId is a field resolver + it has no permission checks as it isn't called directly +*/ +export const getRouteAnnotationsByRouteId: ResolverFn = async ( + { id: rid }, + args, + { sqlClientPool } +) => { + const rows = await query( + sqlClientPool, + Sql.selectRouteAnnotationsByRouteID(rid) + ); + + return rows; +}; + +/* + getPathRoutesByRouteId is a field resolver + it has no permission checks as it isn't called directly +*/ +export const getPathRoutesByRouteId: ResolverFn = async ( + input, + args, + { } +) => { + return JSON.parse(input.pathRoutes); +}; + +/* + updateAutogeneratedRouteConfigOnProject is used to add or update the autogenerated route configuration for a project +*/ +export const updateAutogeneratedRouteConfigOnProject: ResolverFn = async ( + root, + { + input: { + project, + patch + } + }, + { sqlClientPool, hasPermission, userActivityLogger } +) => { + const projectData = await projectHelpers(sqlClientPool).getProjectByName(project) + const projectId = projectData.id + await hasPermission('route', 'update', { + project: projectId + }); + + // this is used to set the beta feature flag on a project from the organization + // this feature will eventually be made generally available and the feature flag will be removed + if (await projectHelpers(sqlClientPool).checkApiRoutesFeature(projectData.organization) === false) { + throw Error(`This feature is currently unavailable`) + } + + // set the updated timestamp on the patch + patch.updated = knex.fn.now() + patch.type = 'project' + patch.typeId = projectData.id + + let pathRoutes + if (patch.pathRoutes) { + checkServiceAutoGenPathRouteRequirements(patch.pathRoutes) + pathRoutes = JSON.stringify(patch.pathRoutes) + } + if (patch.pathRoutes === null) { + pathRoutes = null + } + patch.pathRoutes = pathRoutes + + const createOrUpdateSql = knex('routes_autogenerated_configuration') + .insert({ + ...patch + }) + .onConflict('autogenerated_route_config_type') + .merge({ + ...patch + }) + .toString(); + const { insertId } = await query(sqlClientPool, createOrUpdateSql); + + const auditLog: AuditLog = { + resource: { + id: projectData.id.toString(), + type: AuditType.PROJECT, + details: projectData.name, + }, + }; + if (projectData.organization) { + auditLog.organizationId = projectData.organization; + } + userActivityLogger(`User updated autogenerated route configuration on project '${projectData.name}'`, { + project: '', + event: 'api:updateAutogeneratedRouteConfigOnProject', + payload: { + project: projectData.id, + ...auditLog + } + }); + + const ret = await query(sqlClientPool, Sql.selectAutogeneratedRouteConfigByProjectID(projectData.id)); + return ret[0]; +} + + +/* + removeAutogeneratedRouteConfigFromProject is used to remove the autogenerated route configuration for a project + defaulting to what may be defined at an environment level, or in the .lagoon.yml +*/ +export const removeAutogeneratedRouteConfigFromProject: ResolverFn = async ( + root, + { + project + }, + { sqlClientPool, hasPermission, userActivityLogger } +) => { + const projectData = await projectHelpers(sqlClientPool).getProjectByName(project) + const projectId = projectData.id + await hasPermission('route', 'update', { + project: projectId + }); + + // this is used to set the beta feature flag on a project from the organization + // this feature will eventually be made generally available and the feature flag will be removed + if (await projectHelpers(sqlClientPool).checkApiRoutesFeature(projectData.organization) === false) { + throw Error(`This feature is currently unavailable`) + } + + await query(sqlClientPool, Sql.deleteAutogeneratedRouteConfigForProject(projectData.id)) + + const auditLog: AuditLog = { + resource: { + id: projectData.id.toString(), + type: AuditType.PROJECT, + details: projectData.name, + } + }; + if (projectData.organization) { + auditLog.organizationId = projectData.organization; + } + userActivityLogger(`User removed autogenerated route configuration on project '${projectData.name}'`, { + project: '', + event: 'api:removeAutogeneratedRouteConfigFromProject', + payload: { + project: projectData.id, + ...auditLog + } + }); + + return 'success' +} + +/* + updateAutogeneratedRouteConfigOnEnvironment is used to add or update the autogenerated route configuration for an environment +*/ +export const updateAutogeneratedRouteConfigOnEnvironment: ResolverFn = async ( + root, + { + input: { + project, + environment, + patch + } + }, + { sqlClientPool, hasPermission, userActivityLogger } +) => { + const projectData = await projectHelpers(sqlClientPool).getProjectByName(project) + const projectId = projectData.id + await hasPermission('route', 'update', { + project: projectId + }); + + // this is used to set the beta feature flag on a project from the organization + // this feature will eventually be made generally available and the feature flag will be removed + if (await projectHelpers(sqlClientPool).checkApiRoutesFeature(projectData.organization) === false) { + throw Error(`This feature is currently unavailable`) + } + + let environmentData; + const env = await environmentHelpers(sqlClientPool).getEnvironmentByNameAndProject(environment, projectId) + environmentData = env[0] + + // set the updated timestamp on the patch + patch.updated = knex.fn.now() + patch.type = 'environment' + patch.typeId = environmentData.id + + let pathRoutes + if (patch.pathRoutes) { + checkServiceAutoGenPathRouteRequirements(patch.pathRoutes) + pathRoutes = JSON.stringify(patch.pathRoutes) + } + if (patch.pathRoutes === null) { + pathRoutes = null + } + patch.pathRoutes = pathRoutes + + const createOrUpdateSql = knex('routes_autogenerated_configuration') + .insert({ + ...patch + }) + .onConflict('autogenerated_route_config_type') + .merge({ + ...patch + }) + .toString(); + const { insertId } = await query(sqlClientPool, createOrUpdateSql); + + const auditLog: AuditLog = { + resource: { + id: projectData.id.toString(), + type: AuditType.PROJECT, + details: projectData.name, + }, + linkedResource: { + id: environmentData.id.toString(), + type: AuditType.ENVIRONMENT, + details: environmentData.name + } + }; + if (projectData.organization) { + auditLog.organizationId = projectData.organization; + } + userActivityLogger(`User updated autogenerated route configuration on project '${projectData.name}' environment '${environmentData.name}'`, { + project: '', + event: 'api:updateAutogeneratedRouteConfigOnEnvironment', + payload: { + project: projectData.id, + ...auditLog + } + }); + + const ret = await query(sqlClientPool, Sql.selectAutogeneratedRouteConfigByEnvironmentID(environmentData.id)); + return ret[0]; +} + +/* + removeAutogeneratedRouteConfigFromEnvironment is used to remove the autogenerated route configuration for an environment + defaulting to what may be defined at the project level, or in the .lagoon.yml +*/ +export const removeAutogeneratedRouteConfigFromEnvironment: ResolverFn = async ( + root, + { + project, + environment, + }, + { sqlClientPool, hasPermission, userActivityLogger } +) => { + const projectData = await projectHelpers(sqlClientPool).getProjectByName(project) + const projectId = projectData.id + await hasPermission('route', 'update', { + project: projectId + }); + + // this is used to set the beta feature flag on a project from the organization + // this feature will eventually be made generally available and the feature flag will be removed + if (await projectHelpers(sqlClientPool).checkApiRoutesFeature(projectData.organization) === false) { + throw Error(`This feature is currently unavailable`) + } + + let environmentData; + const env = await environmentHelpers(sqlClientPool).getEnvironmentByNameAndProject(environment, projectId) + environmentData = env[0] + + await query(sqlClientPool, Sql.deleteAutogeneratedRouteConfigForEnvironment(environmentData.id)) + + const auditLog: AuditLog = { + resource: { + id: projectData.id.toString(), + type: AuditType.PROJECT, + details: projectData.name, + }, + linkedResource: { + id: environmentData.id.toString(), + type: AuditType.ENVIRONMENT, + details: environmentData.name + } + }; + if (projectData.organization) { + auditLog.organizationId = projectData.organization; + } + userActivityLogger(`User removed autogenerated route configuration on project '${projectData.name}' environment '${environmentData.name}'`, { + project: '', + event: 'api:removeAutogeneratedRouteConfigFromEnvironment', + payload: { + project: projectData.id, + ...auditLog + } + }); + + return 'success' +} + +/* + getAutogeneratedRouteConfigByEnvironmentId is a field resolver + it has no permission checks as it isn't called directly +*/ +export const getAutogeneratedRouteConfigByEnvironmentId: ResolverFn = async ( + { id }, + args, + { sqlClientPool } +) => { + const rows = await query( + sqlClientPool, + Sql.selectAutogeneratedRouteConfigByEnvironmentID(id) + ); + if (rows.length) { + return rows[0]; + } + return null; +}; + +/* + getAutogeneratedRouteConfigByProjectId is a field resolver + it has no permission checks as it isn't called directly +*/ +export const getAutogeneratedRouteConfigByProjectId: ResolverFn = async ( + { id }, + args, + { sqlClientPool } +) => { + const rows = await query( + sqlClientPool, + Sql.selectAutogeneratedRouteConfigByProjectID(id) + ); + if (rows.length) { + return rows[0]; + } + return null; +}; + +/* + getAutogeneratedRoutePrefixes is a field resolver + it has no permission checks as it isn't called directly +*/ +export const getAutogeneratedPathRoutes: ResolverFn = async ( + input, + args, + { } +) => { + if (!input?.pathRoutes) { + return []; + } + return JSON.parse(input.pathRoutes); +}; + + +/* + getAutogeneratedRoutePrefixes is a field resolver + it has no permission checks as it isn't called directly +*/ +export const getAutogeneratedRoutePrefixes: ResolverFn = async ( + input, + args, + { } +) => { + if (!input?.prefixes) { + return []; + } + return input.prefixes?.split(","); +}; + + + + // // this replaces the prefixes in their entirety if provided + // let joinedPrefixes + // if (autogeneratedRoutePrefixes) { + // joinedPrefixes = autogeneratedRoutePrefixes.join(',') + // } + // if (autogeneratedRoutePrefixes === null) { + // joinedPrefixes = null + // } + + // // this replaces the pathroutes in their entirety if provided + // let pathRoutes + // if (autogeneratedPathRoutes) { + // pathRoutes = JSON.stringify(autogeneratedPathRoutes) + // } + // if (autogeneratedPathRoutes === null) { + // pathRoutes = null + // } \ No newline at end of file diff --git a/services/api/src/resources/routes/sql.ts b/services/api/src/resources/routes/sql.ts new file mode 100644 index 0000000000..ea214b698f --- /dev/null +++ b/services/api/src/resources/routes/sql.ts @@ -0,0 +1,220 @@ +import { knex } from '../../util/db'; + +export const Sql = { + selectRouteByID: (id: number) => + knex('routes') + .where('id', '=', id) + .toString(), + selectRoutesByEnvironmentID: (id: number) => + knex('routes') + .where('environment', id) + .orderBy('id', 'desc') + .toString(), + selectRoutesByDomainAndEnvironmentID: (domain: string, id: number) => + knex('routes') + .where('environment', '=', id) + .andWhere('domain', '=', domain) + .orderBy('id', 'desc') + .toString(), + selectRouteAlternativeDomainsByRouteID: (id: number) => + knex('routes_alternate_domain') + .select('domain') + .where('route_id', '=', id) + .toString(), + selectPathRoutesByRouteID: (id: number) => + knex('routes_path_routes') + .where('route_id', '=', id) + .toString(), + selectRouteByDomainAndProjectID: (domain: string, project: number) => + knex('routes') + .where('project', '=', project) + .andWhere('domain', '=', domain) + .toString(), + selectRouteAlternativeDomainsByDomainAndProjectID: (domain: string, project: number) => + knex('routes_alternate_domain') + .select('routes_alternate_domain.id','routes_alternate_domain.domain','routes.project','routes.environment') + .join('routes', 'routes_alternate_domain.route_id', 'routes.id') + .where('routes.project', '=', project) + .andWhere('routes_alternate_domain.domain', '=', domain) + .toString(), + insertRoute: ({ + id, + domain, + project, + environment, + service, + source, + pathRoutes, + primary, + type, + tlsAcme, + insecure, + hstsEnabled, + hstsPreload, + hstsIncludeSubdomains, + hstsMaxAge, + monitoringPath, + disableRequestVerification, + }: { + id: number, + domain: string, + project: number, + environment: number, + service: string, + source?: string, + pathRoutes?: string, + primary?: boolean, + type?: string, + tlsAcme?: boolean, + insecure?: string, + hstsEnabled?: boolean, + hstsPreload?: boolean, + hstsIncludeSubdomains?: boolean, + hstsMaxAge?: number, + monitoringPath?: string, + disableRequestVerification?: boolean, + }) => + knex('routes') + .insert({ + id, + domain, + environment, + project, + service, + source, + pathRoutes, + primary, + type, + tlsAcme, + insecure, + hstsEnabled, + hstsPreload, + hstsIncludeSubdomains, + hstsMaxAge, + monitoringPath, + disableRequestVerification, + }) + .toString(), + insertRouteAlternativeDomain: ({ + rid, + domain, + }: { + rid: number, + domain: string, + }) => + knex('routes_alternate_domain') + .insert({ + rid, + domain, + }) + .toString(), + deleteRoute: (id: number) => + knex('routes') + .where('id', id) + .del() + .toString(), + deleteRouteAlternativeDomain: (id: number) => + knex('routes_alternate_domain') + .where('id', id) + .del() + .toString(), + deleteRoutesAlternativeDomains: (id: number) => + knex('routes_alternate_domain') + .where('route_id', id) + .del() + .toString(), + insertRouteAnnotation: ({ + routeId, + key, + value + }: { + routeId: number, + key: string, + value: string, + }) => + knex('routes_annotations') + .insert({ + routeId, + key, + value, + }) + .toString(), + selectRouteAnnotationsByRouteID: (id: number) => + knex('routes_annotations') + .where('route_id', '=', id) + .toString(), + deleteRoutesAnnotation: (rid: number, key: string) => + knex('routes_annotations') + .where('route_id', rid) + .andWhere('key', key) + .del() + .toString(), + deleteRoutesAnnotations: (rid: number) => + knex('routes_annotations') + .where('route_id', rid) + .del() + .toString(), + updateRoute: ({ id, patch }: { id: number, patch: { [key: string]: any } }) => + knex('routes') + .where('id', id) + .update(patch) + .toString(), + unsetEnvironmentPrimaryRoute: (environmentId: number, projectId: number) => + knex + .into('routes') + .where('environment', environmentId) + .andWhere('project', projectId) + .update({ primary: false }) + .toString(), + removeRouteFromEnvironment: (routeId: number) => + knex('routes') + .update('pathRoutes', null) + .update('service', null) + .update('environment', null) + .update('type', 'standard') + .update('primary', false) + .where('id', routeId) + .toString(), + removeAllRoutesFromEnvironment: (environmentId: number) => + knex('routes') + .update('pathRoutes', null) + .update('service', null) + .update('environment', null) + .update('primary', false) + .where('environment', environmentId) + .toString(), + deleteAutogeneratedRoutesForEnvironment: (environmentId: number) => + knex('routes') + .where('environment', environmentId) + .andWhere('type', 'autogenerated') + .del() + .toString(), + deleteLagoonYAMLRoutesForEnvironment: (environmentId: number) => + knex('routes') + .where('environment', environmentId) + .andWhere('type', 'yaml') + .del() + .toString(), + selectAutogeneratedRouteConfigByEnvironmentID: (environmentId: number) => + knex('routes_autogenerated_configuration') + .where('type', 'environment') + .andWhere('type_id', environmentId) + .toString(), + deleteAutogeneratedRouteConfigForEnvironment: (environmentId: number) => + knex('routes_autogenerated_configuration') + .where('type', 'environment') + .andWhere('type_id', environmentId) + .del() + .toString(), + selectAutogeneratedRouteConfigByProjectID: (projectId: number) => + knex('routes_autogenerated_configuration') + .where('type', 'project') + .andWhere('type_id', projectId) + .toString(), + deleteAutogeneratedRouteConfigForProject: (projectId: number) => + knex('routes_autogenerated_configuration') + .where('type', 'project') + .andWhere('type_id', projectId) + .del() + .toString(), +}; diff --git a/services/api/src/typeDefs.js b/services/api/src/typeDefs.js index b709f013cc..481a0d496c 100644 --- a/services/api/src/typeDefs.js +++ b/services/api/src/typeDefs.js @@ -800,6 +800,9 @@ const typeDefs = gql` if the project is associated to an organization, and the organization has any retention policies """ retentionPolicies(type: RetentionPolicyType): [RetentionPolicy] + apiRoutes(name: String): [Route] + autogeneratedRouteConfig: AutogeneratedRouteConfig + featureApiRoutes: Boolean @deprecated(reason: "Beta API routes feature flag, will be generally available in a future release and this feature gate will be removed") } """ @@ -901,6 +904,11 @@ const typeDefs = gql` Pending changes tell us if we need to redeploy an environment """ pendingChanges: [EnvironmentPendingChanges] + apiRoutes(domain: String, source: RouteSource): [Route] + autogeneratedRouteConfig: AutogeneratedRouteConfig + autogeneratedRoutes: Boolean + autogeneratedPathRoutes: [AutogeneratedPathRoute] + disableRequestVerification: Boolean } type EnvironmentPendingChanges { @@ -1075,6 +1083,253 @@ const typeDefs = gql` environments: [Environment] } + enum RouteSource { + API + YAML + AUTOGENERATED + } + + enum RouteType { + STANDARD + ACTIVE + STANDBY + } + + enum RouteInsecure { + Allow + Redirect + None + } + + type Route { + id: Int + project: Project + environment: Environment + domain: String + service: String + updated: String + created: String + """ + Used to set automatic certificate generation using the default certificate provisioning system. + Typically this will be LetsEncrypt. + """ + tlsAcme: Boolean + """ + Will handle automatic redirect from http to https on this route. + """ + insecure: RouteInsecure + annotations: [RouteAnnotation] + pathRoutes: [PathRoute] + """ + Is used to indicate if this route is the primary route when attached to an environment. + """ + primary: Boolean + """ + Alternate domains are typically subdomains of the main domain. + """ + alternativeNames: [AlternativeName] + """ + Whether the route is defined in the API or the Lagoon YAML file. + """ + source: RouteSource + """ + Route type determines if this is a standard route, or should be considered as an active or standby route. + Active or Standby routes will transfer between environments that are listed under the projects productionEnvironment and standbyProductionEnvironment. + """ + type: RouteType + """ + If this route is on an environment that idles, request validation could prevent the environment from unidling when not accessed via a browser. + Disabling request verification will allow the environment to unidle from any non-browser requests. + """ + disableRequestVerification: Boolean + hstsEnabled: Boolean + hstsPreload: Boolean + hstsIncludeSubdomains: Boolean + hstsMaxAge: Int + } + + type AutogeneratedRouteConfig { + updated: String + enabled: Boolean + allowPullRequests: Boolean + prefixes: [String] + pathRoutes: [AutogeneratedPathRoute] + disableRequestVerification: Boolean + insecure: RouteInsecure + """ + Used to set automatic certificate generation using the default certificate provisioning system. + Typically this will be LetsEncrypt. + """ + tlsAcme: Boolean + } + + input UpdateProjectAutogeneratedRouteConfigInput { + project: String! + patch: AutogeneratedRouteConfigPatchInput! + } + + input UpdateEnvironmentAutogeneratedRouteConfigInput { + project: String! + environment: String! + patch: AutogeneratedRouteConfigPatchInput! + } + + input AutogeneratedRouteConfigPatchInput { + enabled: Boolean + allowPullRequests: Boolean + prefixes: [String] + pathRoutes: [AutogeneratedPathRouteInput] + disableRequestVerification: Boolean + insecure: RouteInsecure + """ + Used to set automatic certificate generation using the default certificate provisioning system. + Typically this will be LetsEncrypt. + """ + tlsAcme: Boolean + } + + type PathRoute { + id: Int + toService: String + path: String + } + + type AutogeneratedPathRoute { + fromService: String + toService: String + path: String + } + + input AutogeneratedPathRouteInput { + fromService: String + toService: String + path: String + } + + type AlternativeName { + id: Int + domain: String + } + + type RouteAnnotation { + key: String + value: String + } + + input AddRouteToProjectInput { + id: Int + project: String! + domain: String! + environment: String + service: String + primary: Boolean + type: RouteType + alternativeNames: [String] + annotations: [AddRouteAnnotationInput] + pathRoutes: [PathRouteInput] + source: RouteSource + tlsAcme: Boolean + insecure: RouteInsecure + hstsEnabled: Boolean + hstsPreload: Boolean + hstsIncludeSubdomains: Boolean + hstsMaxAge: Int + monitoringPath: String + """ + If this route is on an environment that idles, request validation could prevent the environment from unidling when not accessed via a browser. + Disabling request verification will allow the environment to unidle from any non-browser requests. + """ + disableRequestVerification: Boolean + } + + input UpdateRouteInput { + domain: String! + project: String! + patch: UpdateRoutePatchInput! + } + + input UpdateRoutePatchInput { + tlsAcme: Boolean + insecure: RouteInsecure + hstsEnabled: Boolean + hstsPreload: Boolean + hstsIncludeSubdomains: Boolean + hstsMaxAge: Int + monitoringPath: String + """ + If this route is on an environment that idles, request validation could prevent the environment from unidling when not accessed via a browser. + Disabling request verification will allow the environment to unidle from any non-browser requests. + """ + disableRequestVerification: Boolean + source: RouteSource + } + + input AddOrUpdateEnvironmentRouteInput { + domain: String! + project: String! + environment: String! + service: String! + primary: Boolean + type: RouteType + } + + input ActiveStandbyRouteMoveInput { + domain: String! + project: String! + environment: String! + } + + input RemoveRouteFromEnvironmentInput { + domain: String! + project: String! + environment: String! + } + + input AddRouteAlternativeDomainInput { + id: Int! + alternativeNames: [String]! + } + + input RemoveRouteAlternativeDomainInput { + id: Int! + domain: String! + } + + input AddRouteAnnotationInput { + key: String! + value: String! + } + + input AddRouteAnnotationsInput { + id: Int! + annotations: [AddRouteAnnotationInput]! + } + + input RemoveRouteAnnotationInput { + id: Int! + key: String! + } + + input AddPathRoutesInput { + id: Int! + pathRoutes: [PathRouteInput]! + } + + input RemovePathRoutesInput { + id: Int! + toService: String! + path: String! + } + + input PathRouteInput { + toService: String! + path: String! + } + + input DeleteRouteInput { + id: Int! + } + type OrgUser { id: String email: String @@ -1111,6 +1366,7 @@ const typeDefs = gql` retentionPolicies are the available retention policies to an organization """ retentionPolicies(type: RetentionPolicyType): [RetentionPolicy] + featureApiRoutes: Boolean @deprecated(reason: "Beta API routes feature flag, will be generally available in a future release and this feature gate will be removed") } input AddOrganizationInput { @@ -1123,6 +1379,10 @@ const typeDefs = gql` quotaNotification: Int quotaEnvironment: Int quotaRoute: Int + """ + @deprecated(reason: "Beta API routes feature flag, will be generally available in a future release and this feature gate will be removed") + """ + featureApiRoutes: Boolean } input DeleteOrganizationInput { @@ -1138,6 +1398,10 @@ const typeDefs = gql` quotaNotification: Int quotaEnvironment: Int quotaRoute: Int + """ + @deprecated(reason: "Beta API routes feature flag, will be generally available in a future release and this feature gate will be removed") + """ + featureApiRoutes: Boolean } input UpdateOrganizationInput { @@ -2086,6 +2350,11 @@ const typeDefs = gql` deploymentsDisabled: Int buildImage: String sharedBaasBucket: Boolean + autogeneratedRoutes: Boolean + autogeneratedRoutesPullrequests: Boolean + autogeneratedRoutePrefixes: [String] + autogeneratedPathRoutes: [AutogeneratedPathRouteInput] + disableRequestVerification: Boolean } input UpdateProjectInput { @@ -2255,6 +2524,9 @@ const typeDefs = gql` Timestamp in format 'YYYY-MM-DD hh:mm:ss' """ created: String + autogeneratedRoutes: Boolean + autogeneratedPathRoutes: [AutogeneratedPathRouteInput] + disableRequestVerification: Boolean } input UpdateEnvironmentInput { @@ -2867,6 +3139,24 @@ const typeDefs = gql` Remove an existing history retention policy from a resource type """ removeHistoryRetentionPolicyLink(input: RemoveRetentionPolicyLinkInput!): String + + # ROUTES + addRouteToProject(input: AddRouteToProjectInput!): Route + updateRouteOnProject(input: UpdateRouteInput!): Route + addRouteAlternativeDomains(input: AddRouteAlternativeDomainInput!): Route + removeRouteAlternativeDomain(input: RemoveRouteAlternativeDomainInput!): Route + addRouteAnnotation(input: AddRouteAnnotationsInput!): Route + removeRouteAnnotation(input: RemoveRouteAnnotationInput!): Route + addPathRoutesToRoute(input: AddPathRoutesInput!): Route + removePathRouteFromRoute(input: RemovePathRoutesInput!): Route + addOrUpdateRouteOnEnvironment(input: AddOrUpdateEnvironmentRouteInput!): Route + activeStandbyRouteMove(input: ActiveStandbyRouteMoveInput): Route + removeRouteFromEnvironment(input: RemoveRouteFromEnvironmentInput!): Route + deleteRoute(input: DeleteRouteInput!): String + updateAutogeneratedRouteConfigOnProject(input: UpdateProjectAutogeneratedRouteConfigInput!): AutogeneratedRouteConfig + updateAutogeneratedRouteConfigOnEnvironment(input: UpdateEnvironmentAutogeneratedRouteConfigInput!): AutogeneratedRouteConfig + removeAutogeneratedRouteConfigFromProject(project: String!): String + removeAutogeneratedRouteConfigFromEnvironment(project: String!, environment: String!): String } type Subscription { diff --git a/services/keycloak/lagoon-realm-base-import.json b/services/keycloak/lagoon-realm-base-import.json index b44aab56f9..d2bd2a0104 100644 --- a/services/keycloak/lagoon-realm-base-import.json +++ b/services/keycloak/lagoon-realm-base-import.json @@ -1347,6 +1347,33 @@ "name": "update" } ] + }, + { + "name": "route", + "ownerManagedAccess": false, + "displayName": "route", + "attributes": {}, + "uris": [], + "scopes": [ + { + "name": "add" + }, + { + "name": "view" + }, + { + "name": "update" + }, + { + "name": "add:environment" + }, + { + "name": "remove:environment" + }, + { + "name": "delete" + } + ] } ], "policies": [ @@ -2675,6 +2702,72 @@ "scopes": "[\"add\"]", "applyPolicies": "[\"Default Policy\"]" } + }, + { + "name": "Add Route to Project", + "type": "scope", + "logic": "POSITIVE", + "decisionStrategy": "UNANIMOUS", + "config": { + "resources": "[\"route\"]", + "scopes": "[\"add\"]", + "applyPolicies": "[\"[Lagoon] User has access to project\",\"[Lagoon] Users role for project is Maintainer\"]" + } + }, + { + "name": "Update Route on Project", + "type": "scope", + "logic": "POSITIVE", + "decisionStrategy": "UNANIMOUS", + "config": { + "resources": "[\"route\"]", + "scopes": "[\"update\"]", + "applyPolicies": "[\"[Lagoon] User has access to project\",\"[Lagoon] Users role for project is Maintainer\"]" + } + }, + { + "name": "Add Route to Environment in Project", + "type": "scope", + "logic": "POSITIVE", + "decisionStrategy": "UNANIMOUS", + "config": { + "resources": "[\"route\"]", + "scopes": "[\"add:environment\"]", + "applyPolicies": "[\"[Lagoon] User has access to project\",\"[Lagoon] Users role for project is Maintainer\"]" + } + }, + { + "name": "Remove Route from Environment in Project", + "type": "scope", + "logic": "POSITIVE", + "decisionStrategy": "UNANIMOUS", + "config": { + "resources": "[\"route\"]", + "scopes": "[\"remove:environment\"]", + "applyPolicies": "[\"[Lagoon] User has access to project\",\"[Lagoon] Users role for project is Maintainer\"]" + } + }, + { + "name": "Delete Route from Project", + "type": "scope", + "logic": "POSITIVE", + "decisionStrategy": "UNANIMOUS", + "config": { + "resources": "[\"route\"]", + "scopes": "[\"delete\"]", + "applyPolicies": "[\"[Lagoon] User has access to project\",\"[Lagoon] Users role for project is Maintainer\"]" + } + }, + { + "name": "View Project Routes", + "type": "scope", + "logic": "POSITIVE", + "decisionStrategy": "UNANIMOUS", + "config": { + "resources": "[\"route\"]", + "scopes": "[\"view\"]", + "applyPolicies": "[\"[Lagoon] User has access to project\",\"[Lagoon] Users role for project is Guest\"]" + } } ], "scopes": [ @@ -2932,6 +3025,12 @@ }, { "name": "viewEnvVar" + }, + { + "name": "remove:environment" + }, + { + "name": "add:environment" } ], "decisionStrategy": "UNANIMOUS" diff --git a/services/keycloak/startup-scripts/00-configure-lagoon.sh b/services/keycloak/startup-scripts/00-configure-lagoon.sh index 5a0f27281a..524c4ef8fd 100755 --- a/services/keycloak/startup-scripts/00-configure-lagoon.sh +++ b/services/keycloak/startup-scripts/00-configure-lagoon.sh @@ -966,6 +966,100 @@ function add_lagoon-ui-oidc_impersonator_mappers { --config $CONFIG_PATH } +function add_route_permissions { + local api_client_id=$(/opt/keycloak/bin/kcadm.sh get -r lagoon clients?clientId=api --config $CONFIG_PATH | jq -r '.[0]["id"]') + local add_route_to_project=$(/opt/keycloak/bin/kcadm.sh get -r lagoon clients/$api_client_id/authz/resource-server/permission?name=Add+Route+to+Project --config $CONFIG_PATH) + + + if [ "$add_route_to_project" != "[ ]" ]; then + echo "Route permissions already configured" + return 0 + fi + + echo adding permissions for project routes + + echo Creating resource route + echo '{"name":"route","displayName":"route","scopes":[{"name":"add"},{"name":"view"},{"name":"update"},{"name":"add:environment"},{"name":"remove:environment"},{"name":"delete"}],"attributes":{},"uris":[],"ownerManagedAccess":""}' | /opt/keycloak/bin/kcadm.sh create clients/$api_client_id/authz/resource-server/resource --config $CONFIG_PATH -r ${KEYCLOAK_REALM:-master} -f - + + # Create "View Project Routes" permission + /opt/keycloak/bin/kcadm.sh create clients/$api_client_id/authz/resource-server/permission/scope --config $CONFIG_PATH -r lagoon -f - <