From c5f16171c783c465df7c3d56305283c39fb8e7c7 Mon Sep 17 00:00:00 2001 From: shreddedbacon Date: Fri, 15 Dec 2023 14:11:37 +1100 Subject: [PATCH] feat: support idling and unidling from api with idled status on environment --- node-packages/commons/src/tasks.ts | 10 +- services/actions-handler/go.mod | 2 +- services/actions-handler/go.sum | 2 + .../handler/controller_idling.go | 94 ++++++++++ .../handler/controller_tasks.go | 16 +- services/actions-handler/handler/handler.go | 2 + ...0241127000000_environment_idling_status.js | 21 +++ services/api/src/resolvers.js | 4 +- .../src/resources/environment/resolvers.ts | 161 +++++++++++++++++- services/api/src/typeDefs.js | 5 + 10 files changed, 302 insertions(+), 15 deletions(-) create mode 100644 services/actions-handler/handler/controller_idling.go create mode 100644 services/api/database/migrations/20241127000000_environment_idling_status.js diff --git a/node-packages/commons/src/tasks.ts b/node-packages/commons/src/tasks.ts index 01f5ad44a0..5ebe03306d 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 211de02aa1..baeb94fc8a 100644 --- a/services/actions-handler/go.mod +++ b/services/actions-handler/go.mod @@ -4,7 +4,7 @@ go 1.22 require ( github.com/cheshir/go-mq/v2 v2.0.1 - github.com/uselagoon/machinery v0.0.29 + github.com/uselagoon/machinery v0.0.32-0.20241126220946-4920362e8581 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 ca98698ba3..19359e3734 100644 --- a/services/actions-handler/go.sum +++ b/services/actions-handler/go.sum @@ -844,6 +844,8 @@ github.com/urfave/cli v1.22.1/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtX github.com/urfave/cli v1.22.2/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0= github.com/uselagoon/machinery v0.0.29 h1:invFIPv1Z1xCt8/1ilbiNDuAEPrb+AUO21BnNG+CX8c= github.com/uselagoon/machinery v0.0.29/go.mod h1:X0qguIO9skumMhhT0ap5CKHulKgYzy3TiIn+xlwiFQc= +github.com/uselagoon/machinery v0.0.32-0.20241126220946-4920362e8581 h1:bO1A3yn6veMrz30ANsGnWYvsya7Ato3GgUHYPmoX96M= +github.com/uselagoon/machinery v0.0.32-0.20241126220946-4920362e8581/go.mod h1:RsHzIMOam3hiA4CKR12yANgzdTGy6tz4D19umjMzZyw= 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..d0ba1cbefc --- /dev/null +++ b/services/actions-handler/handler/controller_idling.go @@ -0,0 +1,94 @@ +package handler + +import ( + "context" + "encoding/base64" + "encoding/json" + "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" +) + +type Idled struct { + Idled bool `json:"idled"` +} + +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", m.LagoonAPI.Version, &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 + } + decodeData, _ := base64.StdEncoding.DecodeString(message.Meta.AdvancedData) + idled := &Idled{} + json.Unmarshal(decodeData, idled) + updateEnvironmentPatch := schema.UpdateEnvironmentPatchInput{ + Idled: &idled.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/controller_tasks.go b/services/actions-handler/handler/controller_tasks.go index b967492cd7..6a8bd78634 100644 --- a/services/actions-handler/handler/controller_tasks.go +++ b/services/actions-handler/handler/controller_tasks.go @@ -19,13 +19,13 @@ import ( func (m *Messenger) handleTask(ctx context.Context, messageQueue *mq.MessageQueue, message *schema.LagoonMessage, messageID string) error { prefix := fmt.Sprintf("(messageid:%s) %s/%s: ", messageID, message.Namespace, message.Meta.Task.Name) - log.Println(fmt.Sprintf("%sreceived task status update: %s", prefix, message.Meta.JobStatus)) + log.Printf("%sreceived task status update: %s", prefix, message.Meta.JobStatus) // 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("%sERROR: unable to generate token: %v", prefix, err)) + log.Printf("%sERROR: unable to generate token: %v", prefix, err) } return nil } @@ -48,7 +48,7 @@ func (m *Messenger) handleTask(ctx context.Context, messageQueue *mq.MessageQueu "message": err.Error(), }) if m.EnableDebug { - log.Println(fmt.Sprintf("%sERROR: unable to project information: %v", prefix, err)) + log.Printf("%sERROR: unable to project information: %v", prefix, err) } return err } @@ -70,7 +70,7 @@ func (m *Messenger) handleTask(ctx context.Context, messageQueue *mq.MessageQueu updateProject.StandbyProductionEnvironment = &advTask.ProductionEnvironment } // update the project in the api - updatedProject, err := lagoon.UpdateProject(ctx, int(project.ID), updateProject, l) + updatedProject, err := lagoon.UpdateProject(ctx, project.ID, updateProject, l) if err != nil { // send the log to the lagoon-logs exchange to be processed m.toLagoonLogs(messageQueue, map[string]interface{}{ @@ -80,11 +80,11 @@ func (m *Messenger) handleTask(ctx context.Context, messageQueue *mq.MessageQueu "message": err.Error(), }) if m.EnableDebug { - log.Println(fmt.Sprintf("%sERROR: unable to update project with active/standby result: %v", prefix, err)) + log.Printf("%sERROR: unable to update project with active/standby result: %v", prefix, err) } return err } - log.Println(fmt.Sprintf("%supdated project %s with active/standby result: %v", prefix, message.Meta.Project, "success")) + log.Printf("%supdated project %s with active/standby result: %v", prefix, message.Meta.Project, "success") } } // continue on to updating the task as normal @@ -113,10 +113,10 @@ func (m *Messenger) handleTask(ctx context.Context, messageQueue *mq.MessageQueu "message": err.Error(), }) if m.EnableDebug { - log.Println(fmt.Sprintf("%sERROR: unable to update task: %v", prefix, err)) + log.Printf("%sERROR: unable to update task: %v", prefix, err) } return err } - log.Println(fmt.Sprintf("%supdated task: %s", prefix, message.Meta.JobStatus)) + log.Printf("%supdated task: %s", prefix, message.Meta.JobStatus) return nil } diff --git a/services/actions-handler/handler/handler.go b/services/actions-handler/handler/handler.go index 07abb98d76..9c5fd31c0e 100644 --- a/services/actions-handler/handler/handler.go +++ b/services/actions-handler/handler/handler.go @@ -143,6 +143,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/20241127000000_environment_idling_status.js b/services/api/database/migrations/20241127000000_environment_idling_status.js new file mode 100644 index 0000000000..c2f7ec300a --- /dev/null +++ b/services/api/database/migrations/20241127000000_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 f27b71fb0a..e0044ea76d 100644 --- a/services/api/src/resolvers.js +++ b/services/api/src/resolvers.js @@ -124,6 +124,7 @@ const { getEnvironmentByServiceId, getServiceContainersByServiceId, deleteEnvironmentService, + environmentIdling, } = require('./resources/environment/resolvers'); const { @@ -705,7 +706,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 202abfebd6..018b035007 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'; @@ -691,7 +694,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 } }) ); @@ -716,7 +720,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 } @@ -951,3 +956,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 ffa8e98719..8ecb0cfd27 100644 --- a/services/api/src/typeDefs.js +++ b/services/api/src/typeDefs.js @@ -907,6 +907,10 @@ const typeDefs = gql` kubernetes: Kubernetes kubernetesNamespacePattern: String workflows: [Workflow] + """ + Is the environment currently idled + """ + idled: Boolean } type EnvironmentHitsMonth { @@ -2547,6 +2551,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 {