diff --git a/node-packages/commons/src/tasks.ts b/node-packages/commons/src/tasks.ts index 7e538a3fe2..fe0f70381c 100644 --- a/node-packages/commons/src/tasks.ts +++ b/node-packages/commons/src/tasks.ts @@ -1196,9 +1196,7 @@ export const createMiscTask = async function(taskData: any) { data: { project } } = taskData; - // handle any controller based misc tasks let updatedKey = `deploytarget:${key}`; - let taskId = 'misc-kubernetes'; // determine the deploy target (openshift/kubernetes) for the task to go to // we get this from the environment const result = await getOpenShiftInfoForEnvironment(taskData.data.environment.id); @@ -1373,6 +1371,14 @@ export const createMiscTask = async function(taskData: any) { // build cancellation is just a standard unmodified message miscTaskData.misc = taskData.data.build break; + case 'deploytarget:environment:idling': + // environment idling is used to handle idling or unidling of an an environment + miscTaskData.idling = taskData.data.idling + break; + case 'deploytarget:environment:service': + // environment service is used to handle stop, start, or restarting of a service in an environment + miscTaskData.lagoonService = taskData.data.lagoonService + break; default: miscTaskData.misc = taskData.data.build break; diff --git a/services/actions-handler/go.mod b/services/actions-handler/go.mod index eff685ffad..7f77a05c8b 100644 --- a/services/actions-handler/go.mod +++ b/services/actions-handler/go.mod @@ -4,7 +4,7 @@ go 1.21 require ( github.com/cheshir/go-mq/v2 v2.0.1 - github.com/uselagoon/machinery v0.0.17 + github.com/uselagoon/machinery v0.0.17-0.20240226005245-c8fa2fc9f9ab gopkg.in/matryer/try.v1 v1.0.0-20150601225556-312d2599e12e ) diff --git a/services/actions-handler/go.sum b/services/actions-handler/go.sum index 49235d16ed..31637a4bb4 100644 --- a/services/actions-handler/go.sum +++ b/services/actions-handler/go.sum @@ -839,8 +839,22 @@ github.com/urfave/cli v0.0.0-20171014202726-7bc6a0acffa5/go.mod h1:70zkFmudgCuE/ github.com/urfave/cli v1.20.0/go.mod h1:70zkFmudgCuE/ngEzBv17Jvp/497gISqfk5gWijbERA= github.com/urfave/cli v1.22.1/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0= github.com/urfave/cli v1.22.2/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0= -github.com/uselagoon/machinery v0.0.17 h1:rykgkboGvSX+aMa6MPXtR22DJ67eTPC4WITwpVDuj+Y= -github.com/uselagoon/machinery v0.0.17/go.mod h1:Duljjz/3d/7m0jbmF1nVRDTNaMxMr6m+5LkgjiRrQaU= +github.com/uselagoon/machinery v0.0.17-0.20240108020517-e6622621374c h1:xEUsDgNpM3ZaD0S6dsxQ+j/8hrnMM1HLEhbDUxrqFHE= +github.com/uselagoon/machinery v0.0.17-0.20240108020517-e6622621374c/go.mod h1:Duljjz/3d/7m0jbmF1nVRDTNaMxMr6m+5LkgjiRrQaU= +github.com/uselagoon/machinery v0.0.17-0.20240108024302-9784c2db7f1b h1:eF3O0RMETo+Bk3/I1SLjwkTvqbn/SkwCyTPWOFAl6ek= +github.com/uselagoon/machinery v0.0.17-0.20240108024302-9784c2db7f1b/go.mod h1:Duljjz/3d/7m0jbmF1nVRDTNaMxMr6m+5LkgjiRrQaU= +github.com/uselagoon/machinery v0.0.17-0.20240108025245-bf15fc15ab08 h1:SRCSGYkw6G77d20f5aA0Q/TJYhczybgEU4g7SodVsNg= +github.com/uselagoon/machinery v0.0.17-0.20240108025245-bf15fc15ab08/go.mod h1:Duljjz/3d/7m0jbmF1nVRDTNaMxMr6m+5LkgjiRrQaU= +github.com/uselagoon/machinery v0.0.17-0.20240108043534-c066518389be h1:cOBkrQa7DYDnIVZebJViIc9XEggFKYjfSKzqHC9VckE= +github.com/uselagoon/machinery v0.0.17-0.20240108043534-c066518389be/go.mod h1:Duljjz/3d/7m0jbmF1nVRDTNaMxMr6m+5LkgjiRrQaU= +github.com/uselagoon/machinery v0.0.17-0.20240108043655-6cf0c0b42884 h1:33GmXqKEkpPzsspIse3xx/aN/Leh+Jdl9QcRrbnEktg= +github.com/uselagoon/machinery v0.0.17-0.20240108043655-6cf0c0b42884/go.mod h1:Duljjz/3d/7m0jbmF1nVRDTNaMxMr6m+5LkgjiRrQaU= +github.com/uselagoon/machinery v0.0.17-0.20240108050446-30ff0a7df794 h1:2LP/ytk7sY6BrVY67PizVVYF6EnZKPzwKEOJg8JFY1I= +github.com/uselagoon/machinery v0.0.17-0.20240108050446-30ff0a7df794/go.mod h1:Duljjz/3d/7m0jbmF1nVRDTNaMxMr6m+5LkgjiRrQaU= +github.com/uselagoon/machinery v0.0.17-0.20240108054822-78639cc0a1f3 h1:DYklzy44C1s1a1O6LqAi8RUpuqDzTzJTnW9IRQ8J91k= +github.com/uselagoon/machinery v0.0.17-0.20240108054822-78639cc0a1f3/go.mod h1:Duljjz/3d/7m0jbmF1nVRDTNaMxMr6m+5LkgjiRrQaU= +github.com/uselagoon/machinery v0.0.17-0.20240226005245-c8fa2fc9f9ab h1:lBAsDSwVaj7l7Bt53yiWlll0mq4ZsEGtj/queCK+2OY= +github.com/uselagoon/machinery v0.0.17-0.20240226005245-c8fa2fc9f9ab/go.mod h1:Duljjz/3d/7m0jbmF1nVRDTNaMxMr6m+5LkgjiRrQaU= github.com/vishvananda/netlink v0.0.0-20181108222139-023a6dafdcdf/go.mod h1:+SR5DhBJrl6ZM7CoCKvpw5BKroDKQ+PJqOg65H/2ktk= github.com/vishvananda/netlink v1.1.0/go.mod h1:cTgwzPIzzgDAYoQrMm0EdrjRUBkTqKYppBueQtXaqoE= github.com/vishvananda/netlink v1.1.1-0.20201029203352-d40f9887b852/go.mod h1:twkDnbuQxJYemMlGd4JFIcuhgX83tXhKS2B/PRMpOho= diff --git a/services/actions-handler/handler/controller_idling.go b/services/actions-handler/handler/controller_idling.go new file mode 100644 index 0000000000..19cb958c2d --- /dev/null +++ b/services/actions-handler/handler/controller_idling.go @@ -0,0 +1,85 @@ +package handler + +import ( + "context" + "fmt" + "log" + "time" + + mq "github.com/cheshir/go-mq/v2" + "github.com/uselagoon/machinery/api/lagoon" + lclient "github.com/uselagoon/machinery/api/lagoon/client" + "github.com/uselagoon/machinery/api/schema" + "github.com/uselagoon/machinery/utils/jwt" +) + +func (m *Messenger) handleIdling(ctx context.Context, messageQueue *mq.MessageQueue, message *schema.LagoonMessage, messageID string) error { + prefix := fmt.Sprintf("(messageid:%s) %s: ", messageID, message.Namespace) + log.Println(fmt.Sprintf("%sreceived idling environment status update", prefix)) + // generate a lagoon token with a expiry of 60 seconds from now + token, err := jwt.GenerateAdminToken(m.LagoonAPI.TokenSigningKey, m.LagoonAPI.JWTAudience, m.LagoonAPI.JWTSubject, m.LagoonAPI.JWTIssuer, time.Now().Unix(), 60) + if err != nil { + // the token wasn't generated + if m.EnableDebug { + log.Println(fmt.Sprintf("ERROR: unable to generate token: %v", err)) + } + return nil + } + // set up a lagoon client for use in the following process + l := lclient.New(m.LagoonAPI.Endpoint, "actions-handler", &token, false) + var environmentID uint + // determine the environment id from the message + if message.Meta.ProjectID == nil && message.Meta.EnvironmentID == nil { + project, err := lagoon.GetMinimalProjectByName(ctx, message.Meta.Project, l) + if err != nil { + // send the log to the lagoon-logs exchange to be processed + m.toLagoonLogs(messageQueue, map[string]interface{}{ + "severity": "error", + "event": fmt.Sprintf("actions-handler:%s:failed", "updateEnvironment"), + "meta": project, + "message": err.Error(), + }) + if m.EnableDebug { + log.Println(fmt.Sprintf("%sERROR: unable to get project - %v", prefix, err)) + } + return err + } + environment, err := lagoon.GetEnvironmentByName(ctx, message.Meta.Environment, project.ID, l) + if err != nil { + // send the log to the lagoon-logs exchange to be processed + m.toLagoonLogs(messageQueue, map[string]interface{}{ + "severity": "error", + "event": fmt.Sprintf("actions-handler:%s:failed", "updateEnvironment"), + "meta": project, + "message": err.Error(), + }) + if m.EnableDebug { + log.Println(fmt.Sprintf("%sERROR: unable to get environment - %v", prefix, err)) + } + return err + } + environmentID = environment.ID + } else { + // pull the id from the message + environmentID = *message.Meta.EnvironmentID + } + updateEnvironmentPatch := schema.UpdateEnvironmentPatchInput{ + Idled: &message.Idled, + } + updateEnvironment, err := lagoon.UpdateEnvironment(ctx, environmentID, updateEnvironmentPatch, l) + if err != nil { + // send the log to the lagoon-logs exchange to be processed + m.toLagoonLogs(messageQueue, map[string]interface{}{ + "severity": "error", + "event": fmt.Sprintf("actions-handler:%s:failed", "updateDeployment"), + "meta": updateEnvironment, + "message": err.Error(), + }) + if m.EnableDebug { + log.Println(fmt.Sprintf("%sERROR: unable to update environment - %v", prefix, err)) + } + return err + } + log.Println(fmt.Sprintf("%supdated environment", prefix)) + return nil +} diff --git a/services/actions-handler/handler/handler.go b/services/actions-handler/handler/handler.go index 44bfca187f..b7809182af 100644 --- a/services/actions-handler/handler/handler.go +++ b/services/actions-handler/handler/handler.go @@ -142,6 +142,8 @@ func (m *Messenger) Consumer() { err = m.handleRemoval(ctx, messageQueue, logMsg, messageID) case "task": err = m.handleTask(ctx, messageQueue, logMsg, messageID) + case "idling": + err = m.handleIdling(ctx, messageQueue, logMsg, messageID) } // if there aren't any errors, then ack the message, an error indicates that there may have been an issue with the api handling the request // skipping this means the message will remain in the queue diff --git a/services/api/database/migrations/20240226000000_environment_idling_status.js b/services/api/database/migrations/20240226000000_environment_idling_status.js new file mode 100644 index 0000000000..c2f7ec300a --- /dev/null +++ b/services/api/database/migrations/20240226000000_environment_idling_status.js @@ -0,0 +1,21 @@ +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.up = async function(knex) { + return knex.schema + .alterTable('environment', (table) => { + table.boolean('idled').notNullable().defaultTo(0); + }) +}; + +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.down = async function(knex) { + return knex.schema + .alterTable('environment', (table) => { + table.dropColumn('idled'); + }) +}; diff --git a/services/api/src/resolvers.js b/services/api/src/resolvers.js index 8c192b3174..86b6b48fd9 100644 --- a/services/api/src/resolvers.js +++ b/services/api/src/resolvers.js @@ -128,6 +128,7 @@ const { getEnvironmentByServiceId, getServiceContainersByServiceId, deleteEnvironmentService, + environmentIdling, } = require('./resources/environment/resolvers'); const { @@ -730,7 +731,8 @@ const resolvers = { removeUserFromOrganizationGroups, bulkImportProjectsAndGroupsToOrganization, addOrUpdateEnvironmentService, - deleteEnvironmentService + deleteEnvironmentService, + environmentIdling, }, Subscription: { backupChanged: backupSubscriber, diff --git a/services/api/src/resources/environment/resolvers.ts b/services/api/src/resources/environment/resolvers.ts index 7878a39d25..c39dd35a5f 100644 --- a/services/api/src/resources/environment/resolvers.ts +++ b/services/api/src/resources/environment/resolvers.ts @@ -1,6 +1,9 @@ import * as R from 'ramda'; import { sendToLagoonLogs } from '@lagoon/commons/dist/logs/lagoon-logger'; -import { createRemoveTask } from '@lagoon/commons/dist/tasks'; +import { + createRemoveTask, + createMiscTask +} from '@lagoon/commons/dist/tasks'; import { ResolverFn } from '../'; import { logger } from '../../loggers/logger'; import { isPatchEmpty, query, knex } from '../../util/db'; @@ -660,7 +663,8 @@ 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, + idled: input.patch.idled } }) ); @@ -685,7 +689,8 @@ 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, + idled: input.patch.idled }, data: withK8s } @@ -941,3 +946,153 @@ export const getServiceContainersByServiceId: ResolverFn = async ( ); return await rows; }; + +export const environmentIdling = async ( + root, + input, + { sqlClientPool, hasPermission, userActivityLogger } +) => { + const environment = await Helpers(sqlClientPool).getEnvironmentById(input.id); + + if (!environment) { + throw new Error( + 'Invalid environment ID' + ); + } + + await hasPermission('environment', 'view', { + project: environment.project + }); + + // don't try idle if the environment is already idled or unidled + if (environment.idled && input.idle) { + throw new Error( + `environment is already idled` + ); + } + if (!environment.idled && !input.idle) { + throw new Error( + `environment is already unidled` + ); + } + + const project = await projectHelpers(sqlClientPool).getProjectById( + environment.project + ); + + await hasPermission('deployment', 'cancel', { + project: project.id + }); + + const data = { + environment, + project, + idling: { + idle: input.idle, + forceScale: input.disableAutomaticUnidling + } + }; + + userActivityLogger(`User requested environment idling for '${environment.name}'`, { + project: '', + event: 'api:idleEnvironment', + payload: { + project: project.name, + environment: environment.name, + idle: input.idle, + disableAutomaticUnidling: input.disableAutomaticUnidling, + } + }); + + try { + await createMiscTask({ key: 'environment:idling', data }); + return 'success'; + } catch (error) { + sendToLagoonLogs( + 'error', + '', + '', + 'api:idleEnvironment', + { environment: environment.id }, + `Environment idle attempt possibly failed, reason: ${error}` + ); + throw new Error( + error.message + ); + } +}; + +export const environmentService = async ( + root, + input, + { sqlClientPool, hasPermission, userActivityLogger } +) => { + const environment = await Helpers(sqlClientPool).getEnvironmentById(input.id); + + if (!environment) { + throw new Error( + 'Invalid environment ID' + ); + } + + await hasPermission('environment', 'view', { + project: environment.project + }); + + // don't try idle if the environment is already idled or unidled + if (environment.idled && input.idle) { + throw new Error( + `environment is already idled` + ); + } + if (!environment.idled && !input.idle) { + throw new Error( + `environment is already unidled` + ); + } + + const project = await projectHelpers(sqlClientPool).getProjectById( + environment.project + ); + + await hasPermission('deployment', 'cancel', { + project: project.id + }); + + const data = { + environment, + project, + lagoonService: { + name: input.serviceName, + state: input.state + } + }; + + userActivityLogger(`User requested environment idling for '${environment.name}'`, { + project: '', + event: 'api:idleEnvironment', + payload: { + project: project.name, + environment: environment.name, + idle: input.idle, + disableAutomaticUnidling: input.disableAutomaticUnidling, + } + }); + + try { + await createMiscTask({ key: 'environment:idling', data }); + return 'success'; + } catch (error) { + sendToLagoonLogs( + 'error', + '', + '', + 'api:idleEnvironment', + { environment: environment.id }, + `Environment idle attempt possibly failed, reason: ${error}` + ); + throw new Error( + error.message + ); + } +}; diff --git a/services/api/src/typeDefs.js b/services/api/src/typeDefs.js index 40f116ac0c..56969cdbb6 100644 --- a/services/api/src/typeDefs.js +++ b/services/api/src/typeDefs.js @@ -920,6 +920,10 @@ const typeDefs = gql` kubernetes: Kubernetes kubernetesNamespacePattern: String workflows: [Workflow] + """ + Is the environment currently idled + """ + idled: Boolean } type EnvironmentHitsMonth { @@ -2530,6 +2534,7 @@ const typeDefs = gql` bulkImportProjectsAndGroupsToOrganization(input: AddProjectToOrganizationInput, detachNotification: Boolean): ProjectGroupsToOrganization addOrUpdateEnvironmentService(input: AddEnvironmentServiceInput!): EnvironmentService deleteEnvironmentService(input: DeleteEnvironmentServiceInput!): String + environmentIdling(id: Int!, idle: Boolean!, disableAutomaticUnidling: Boolean): String } type Subscription {