Custom step file support

pull/437/head
Frostebite 2022-10-08 00:32:52 +01:00
parent 9fe9233a6a
commit b5c63e451d
12 changed files with 302 additions and 87 deletions

191
dist/index.js vendored
View File

@ -4259,7 +4259,7 @@ class RemoteClient {
const fileContentsObject = yaml_1.default.parse(fileContents.toString());
if (fileContentsObject.hook === hookLifecycle) {
remote_client_logger_1.RemoteClientLogger.log(`Active Hook File ${file} contents: ${fileContents}`);
yield cloud_runner_system_1.CloudRunnerSystem.Run(fileContents);
yield cloud_runner_system_1.CloudRunnerSystem.Run(fileContentsObject.commands);
}
}
});
@ -4377,6 +4377,148 @@ class Hook {
exports.Hook = Hook;
/***/ }),
/***/ 96455:
/***/ (function(__unused_webpack_module, exports, __nccwpck_require__) {
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
Object.defineProperty(o, k2, { enumerable: true, get: function() { return m[k]; } });
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
__setModuleDefault(result, mod);
return result;
};
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
return new (P || (P = Promise))(function (resolve, reject) {
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
step((generator = generator.apply(thisArg, _arguments || [])).next());
});
};
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", ({ value: true }));
exports.CustomStep = exports.CloudRunnerCustomSteps = void 0;
const yaml_1 = __importDefault(__nccwpck_require__(44603));
const cloud_runner_1 = __importDefault(__nccwpck_require__(79144));
const core = __importStar(__nccwpck_require__(42186));
const custom_workflow_1 = __nccwpck_require__(3786);
const remote_client_logger_1 = __nccwpck_require__(59412);
const path_1 = __importDefault(__nccwpck_require__(71017));
const fs = __importStar(__nccwpck_require__(57147));
const cloud_runner_folders_1 = __nccwpck_require__(13527);
const cloud_runner_logger_1 = __importDefault(__nccwpck_require__(22855));
const input_1 = __importDefault(__nccwpck_require__(91933));
class CloudRunnerCustomSteps {
static GetCustomStepsFromFiles(hookLifecycle) {
const results = [];
remote_client_logger_1.RemoteClientLogger.log(`GetCustomStepFiles: ${hookLifecycle}`);
const gameCiCustomStepsPath = path_1.default.join(cloud_runner_folders_1.CloudRunnerFolders.repoPathAbsolute, `game-ci`, `steps`);
const files = fs.readdirSync(gameCiCustomStepsPath);
for (const file of files) {
const fileContents = fs.readFileSync(path_1.default.join(gameCiCustomStepsPath, file), `utf8`);
const fileContentsObject = yaml_1.default.parse(fileContents.toString());
if (fileContentsObject.hook === hookLifecycle) {
remote_client_logger_1.RemoteClientLogger.log(`Active Step File ${file} contents: ${fileContents}`);
results.push(...CloudRunnerCustomSteps.ParseSteps(fileContents));
}
}
return results;
}
static ParseSteps(steps) {
let object;
try {
if (cloud_runner_1.default.buildParameters.cloudRunnerIntegrationTests) {
cloud_runner_logger_1.default.log(`Parsing build steps: ${steps}`);
}
object = yaml_1.default.parse(steps);
if (object === undefined) {
throw new Error(`Failed to parse ${steps}`);
}
}
catch (error) {
cloud_runner_logger_1.default.log(`failed to parse a custom job "${steps}"`);
throw error;
}
for (const step of object) {
step.secrets = step.secrets.map((x) => {
return {
ParameterKey: x.name,
EnvironmentVariable: input_1.default.ToEnvVarFormat(x.name),
ParameterValue: x.value,
};
});
}
return object;
}
static RunPostBuildSteps(cloudRunnerStepState) {
return __awaiter(this, void 0, void 0, function* () {
let output = ``;
let steps = [];
if (cloud_runner_1.default.buildParameters.postBuildSteps !== '') {
steps = CloudRunnerCustomSteps.ParseSteps(cloud_runner_1.default.buildParameters.postBuildSteps);
}
const fileSteps = CloudRunnerCustomSteps.GetCustomStepsFromFiles(`after`);
if (fileSteps.length > 0) {
steps = [...steps, ...fileSteps];
}
if (steps.length > 0) {
if (!cloud_runner_1.default.buildParameters.isCliMode)
core.startGroup('post build steps');
output += yield custom_workflow_1.CustomWorkflow.runCustomJob(steps, cloudRunnerStepState.environment, cloudRunnerStepState.secrets);
if (!cloud_runner_1.default.buildParameters.isCliMode)
core.endGroup();
}
return output;
});
}
static RunPreBuildSteps(cloudRunnerStepState) {
return __awaiter(this, void 0, void 0, function* () {
let output = ``;
let steps = [];
if (cloud_runner_1.default.buildParameters.preBuildSteps !== '') {
steps = CloudRunnerCustomSteps.ParseSteps(cloud_runner_1.default.buildParameters.preBuildSteps);
}
steps = [...steps, ...CloudRunnerCustomSteps.GetCustomStepsFromFiles(`before`)];
if (steps.length > 0) {
if (!cloud_runner_1.default.buildParameters.isCliMode)
core.startGroup('pre build steps');
output += yield custom_workflow_1.CustomWorkflow.runCustomJob(steps, cloudRunnerStepState.environment, cloudRunnerStepState.secrets);
if (!cloud_runner_1.default.buildParameters.isCliMode)
core.endGroup();
}
return output;
});
}
}
exports.CloudRunnerCustomSteps = CloudRunnerCustomSteps;
class CustomStep {
constructor() {
this.secrets = new Array();
this.image = `ubuntu`;
}
}
exports.CustomStep = CustomStep;
/***/ }),
/***/ 13527:
@ -5083,13 +5225,13 @@ Object.defineProperty(exports, "__esModule", ({ value: true }));
exports.BuildAutomationWorkflow = void 0;
const cloud_runner_logger_1 = __importDefault(__nccwpck_require__(22855));
const cloud_runner_folders_1 = __nccwpck_require__(13527);
const custom_workflow_1 = __nccwpck_require__(3786);
const core = __importStar(__nccwpck_require__(42186));
const cloud_runner_custom_hooks_1 = __nccwpck_require__(58873);
const path_1 = __importDefault(__nccwpck_require__(71017));
const cloud_runner_1 = __importDefault(__nccwpck_require__(79144));
const cloud_runner_options_1 = __importDefault(__nccwpck_require__(96552));
const shared_workspace_locking_1 = __importDefault(__nccwpck_require__(67170));
const cloud_runner_custom_steps_1 = __nccwpck_require__(96455);
class BuildAutomationWorkflow {
run(cloudRunnerStepState) {
return __awaiter(this, void 0, void 0, function* () {
@ -5117,13 +5259,7 @@ class BuildAutomationWorkflow {
];
}
let output = '';
if (cloud_runner_1.default.buildParameters.preBuildSteps !== '') {
if (!cloud_runner_1.default.buildParameters.isCliMode)
core.startGroup('pre build steps');
output += yield custom_workflow_1.CustomWorkflow.runCustomJob(cloud_runner_1.default.buildParameters.preBuildSteps, cloudRunnerStepState.environment, cloudRunnerStepState.secrets);
if (!cloud_runner_1.default.buildParameters.isCliMode)
core.endGroup();
}
output += cloud_runner_custom_steps_1.CloudRunnerCustomSteps.RunPreBuildSteps(cloudRunnerStepState);
cloud_runner_logger_1.default.logWithTime('Configurable pre build step(s) time');
if (!cloud_runner_1.default.buildParameters.isCliMode)
core.startGroup('build');
@ -5134,13 +5270,7 @@ class BuildAutomationWorkflow {
if (!cloud_runner_1.default.buildParameters.isCliMode)
core.endGroup();
cloud_runner_logger_1.default.logWithTime('Build time');
if (cloud_runner_1.default.buildParameters.postBuildSteps !== '') {
if (!cloud_runner_1.default.buildParameters.isCliMode)
core.startGroup('post build steps');
output += yield custom_workflow_1.CustomWorkflow.runCustomJob(cloud_runner_1.default.buildParameters.postBuildSteps, cloudRunnerStepState.environment, cloudRunnerStepState.secrets);
if (!cloud_runner_1.default.buildParameters.isCliMode)
core.endGroup();
}
output += cloud_runner_custom_steps_1.CloudRunnerCustomSteps.RunPostBuildSteps(cloudRunnerStepState);
cloud_runner_logger_1.default.logWithTime('Configurable post build step(s) time');
if (cloud_runner_options_1.default.retainWorkspaces) {
yield shared_workspace_locking_1.default.ReleaseWorkspace(`test-workspace-${cloud_runner_1.default.buildParameters.buildGuid}`, cloud_runner_1.default.buildParameters.buildGuid, cloud_runner_1.default.buildParameters);
@ -5223,34 +5353,21 @@ Object.defineProperty(exports, "__esModule", ({ value: true }));
exports.CustomWorkflow = void 0;
const cloud_runner_logger_1 = __importDefault(__nccwpck_require__(22855));
const cloud_runner_folders_1 = __nccwpck_require__(13527);
const yaml_1 = __importDefault(__nccwpck_require__(44603));
const __1 = __nccwpck_require__(41359);
const cloud_runner_custom_steps_1 = __nccwpck_require__(96455);
class CustomWorkflow {
static runCustomJobFromString(buildSteps, environmentVariables, secrets) {
return __awaiter(this, void 0, void 0, function* () {
return yield CustomWorkflow.runCustomJob(cloud_runner_custom_steps_1.CloudRunnerCustomSteps.ParseSteps(buildSteps), environmentVariables, secrets);
});
}
static runCustomJob(buildSteps, environmentVariables, secrets) {
return __awaiter(this, void 0, void 0, function* () {
try {
cloud_runner_logger_1.default.log(`Cloud Runner is running in custom job mode`);
if (__1.CloudRunner.buildParameters.cloudRunnerIntegrationTests) {
cloud_runner_logger_1.default.log(`Parsing build steps: ${buildSteps}`);
}
try {
buildSteps = yaml_1.default.parse(buildSteps);
}
catch (error) {
cloud_runner_logger_1.default.log(`failed to parse a custom job "${buildSteps}"`);
throw error;
}
let output = '';
for (const step of buildSteps) {
const stepSecrets = step.secrets.map((x) => {
const secret = {
ParameterKey: x.name,
EnvironmentVariable: __1.Input.ToEnvVarFormat(x.name),
ParameterValue: x.value,
};
return secret;
});
output += yield __1.CloudRunner.Provider.runTask(__1.CloudRunner.buildParameters.buildGuid, step['image'], step['commands'], `/${cloud_runner_folders_1.CloudRunnerFolders.buildVolumeFolder}`, `/${cloud_runner_folders_1.CloudRunnerFolders.projectPathAbsolute}/`, environmentVariables, [...secrets, ...stepSecrets]);
output += yield __1.CloudRunner.Provider.runTask(__1.CloudRunner.buildParameters.buildGuid, step['image'], step['commands'], `/${cloud_runner_folders_1.CloudRunnerFolders.buildVolumeFolder}`, `/${cloud_runner_folders_1.CloudRunnerFolders.projectPathAbsolute}/`, environmentVariables, [...secrets, ...step.secrets]);
}
return output;
}
@ -5293,7 +5410,7 @@ class WorkflowCompositionRoot {
return __awaiter(this, void 0, void 0, function* () {
try {
if (cloud_runner_1.default.buildParameters.customJob !== '') {
return yield custom_workflow_1.CustomWorkflow.runCustomJob(cloud_runner_1.default.buildParameters.customJob, cloudRunnerStepState.environment, cloudRunnerStepState.secrets);
return yield custom_workflow_1.CustomWorkflow.runCustomJobFromString(cloud_runner_1.default.buildParameters.customJob, cloudRunnerStepState.environment, cloudRunnerStepState.secrets);
}
return yield new build_automation_workflow_1.BuildAutomationWorkflow().run(new cloud_runner_step_state_1.CloudRunnerStepState(cloudRunnerStepState.image.toString(), cloudRunnerStepState.environment, cloudRunnerStepState.secrets));
}

2
dist/index.js.map vendored

File diff suppressed because one or more lines are too long

View File

@ -1,3 +1,3 @@
hook: after-build
run: |
echo "after-build test!"
commands: |
echo "after-build hook test!"

View File

@ -1,3 +1,3 @@
hook: before-build
run: |
echo "before-build test!!"
commands: |
echo "before-build hook test!!"

View File

@ -0,0 +1,3 @@
hook: after-build
commands: |
echo "after-build step test!"

View File

@ -0,0 +1,3 @@
hook: before-build
commands: |
echo "before-build step test!"

View File

@ -136,7 +136,7 @@ export class RemoteClient {
const fileContentsObject = YAML.parse(fileContents.toString());
if (fileContentsObject.hook === hookLifecycle) {
RemoteClientLogger.log(`Active Hook File ${file} contents: ${fileContents}`);
await CloudRunnerSystem.Run(fileContents);
await CloudRunnerSystem.Run(fileContentsObject.commands);
}
}
}

View File

@ -0,0 +1,108 @@
import YAML from 'yaml';
import CloudRunnerSecret from './cloud-runner-secret';
import CloudRunner from '../cloud-runner';
import * as core from '@actions/core';
import { CustomWorkflow } from '../workflows/custom-workflow';
import { RemoteClientLogger } from '../remote-client/remote-client-logger';
import path from 'path';
import * as fs from 'fs';
import { CloudRunnerFolders } from './cloud-runner-folders';
import CloudRunnerLogger from './cloud-runner-logger';
import Input from '../../input';
export class CloudRunnerCustomSteps {
static GetCustomStepsFromFiles(hookLifecycle: string): CustomStep[] {
const results: CustomStep[] = [];
RemoteClientLogger.log(`GetCustomStepFiles: ${hookLifecycle}`);
const gameCiCustomStepsPath = path.join(CloudRunnerFolders.repoPathAbsolute, `game-ci`, `steps`);
const files = fs.readdirSync(gameCiCustomStepsPath);
for (const file of files) {
const fileContents = fs.readFileSync(path.join(gameCiCustomStepsPath, file), `utf8`);
const fileContentsObject = YAML.parse(fileContents.toString());
if (fileContentsObject.hook === hookLifecycle) {
RemoteClientLogger.log(`Active Step File ${file} contents: ${fileContents}`);
results.push(...CloudRunnerCustomSteps.ParseSteps(fileContents));
}
}
return results;
}
public static ParseSteps(steps: string): CustomStep[] {
let object: any;
try {
if (CloudRunner.buildParameters.cloudRunnerIntegrationTests) {
CloudRunnerLogger.log(`Parsing build steps: ${steps}`);
}
object = YAML.parse(steps);
if (object === undefined) {
throw new Error(`Failed to parse ${steps}`);
}
} catch (error) {
CloudRunnerLogger.log(`failed to parse a custom job "${steps}"`);
throw error;
}
for (const step of object) {
step.secrets = step.secrets.map((x) => {
return {
ParameterKey: x.name,
EnvironmentVariable: Input.ToEnvVarFormat(x.name),
ParameterValue: x.value,
};
});
}
return object;
}
static async RunPostBuildSteps(cloudRunnerStepState) {
let output = ``;
let steps: CustomStep[] = [];
if (CloudRunner.buildParameters.postBuildSteps !== '') {
steps = CloudRunnerCustomSteps.ParseSteps(CloudRunner.buildParameters.postBuildSteps);
}
const fileSteps = CloudRunnerCustomSteps.GetCustomStepsFromFiles(`after`);
if (fileSteps.length > 0) {
steps = [...steps, ...fileSteps];
}
if (steps.length > 0) {
if (!CloudRunner.buildParameters.isCliMode) core.startGroup('post build steps');
output += await CustomWorkflow.runCustomJob(
steps,
cloudRunnerStepState.environment,
cloudRunnerStepState.secrets,
);
if (!CloudRunner.buildParameters.isCliMode) core.endGroup();
}
return output;
}
static async RunPreBuildSteps(cloudRunnerStepState) {
let output = ``;
let steps: CustomStep[] = [];
if (CloudRunner.buildParameters.preBuildSteps !== '') {
steps = CloudRunnerCustomSteps.ParseSteps(CloudRunner.buildParameters.preBuildSteps);
}
steps = [...steps, ...CloudRunnerCustomSteps.GetCustomStepsFromFiles(`before`)];
if (steps.length > 0) {
if (!CloudRunner.buildParameters.isCliMode) core.startGroup('pre build steps');
output += await CustomWorkflow.runCustomJob(
steps,
cloudRunnerStepState.environment,
cloudRunnerStepState.secrets,
);
if (!CloudRunner.buildParameters.isCliMode) core.endGroup();
}
return output;
}
}
export class CustomStep {
public commands;
public secrets: CloudRunnerSecret[] = new Array<CloudRunnerSecret>();
public name;
public image: string = `ubuntu`;
public hook!: string[];
}

View File

@ -35,14 +35,19 @@ describe('Cloud Runner Custom Hooks', () => {
const build2ContainsBuildSucceeded = results2.includes('Build succeeded');
const build2ContainsPreBuildHookMessage = results2.includes('RunCustomHookFiles: before-build');
const build2ContainsPostBuildHookMessage = results2.includes('RunCustomHookFiles: after-build');
const build2ContainsPreBuildHookRunMessage = results2.includes('before-build test!');
const build2ContainsPostBuildHookRunMessage = results2.includes('after-build test!');
const build2ContainsPreBuildHookRunMessage = results2.includes('before-build hook test!');
const build2ContainsPostBuildHookRunMessage = results2.includes('after-build hook test!');
const build2ContainsPreBuildStepMessage = results2.includes('before-build step test!');
const build2ContainsPostBuildStepMessage = results2.includes('after-build step test!');
expect(build2ContainsBuildSucceeded).toBeTruthy();
expect(build2ContainsPreBuildHookMessage).toBeTruthy();
expect(build2ContainsPostBuildHookMessage).toBeTruthy();
expect(build2ContainsPreBuildHookRunMessage).toBeTruthy();
expect(build2ContainsPostBuildHookRunMessage).toBeTruthy();
expect(build2ContainsPreBuildStepMessage).toBeTruthy();
expect(build2ContainsPostBuildStepMessage).toBeTruthy();
}, 10000000);
}
});

View File

@ -1,7 +1,6 @@
import CloudRunnerLogger from '../services/cloud-runner-logger';
import { CloudRunnerFolders } from '../services/cloud-runner-folders';
import { CloudRunnerStepState } from '../cloud-runner-step-state';
import { CustomWorkflow } from './custom-workflow';
import { WorkflowInterface } from './workflow-interface';
import * as core from '@actions/core';
import { CloudRunnerCustomHooks } from '../services/cloud-runner-custom-hooks';
@ -9,6 +8,7 @@ import path from 'path';
import CloudRunner from '../cloud-runner';
import CloudRunnerOptions from '../cloud-runner-options';
import SharedWorkspaceLocking from '../../cli/shared-workspace-locking';
import { CloudRunnerCustomSteps } from '../services/cloud-runner-custom-steps';
export class BuildAutomationWorkflow implements WorkflowInterface {
async run(cloudRunnerStepState: CloudRunnerStepState) {
@ -43,15 +43,8 @@ export class BuildAutomationWorkflow implements WorkflowInterface {
}
let output = '';
if (CloudRunner.buildParameters.preBuildSteps !== '') {
if (!CloudRunner.buildParameters.isCliMode) core.startGroup('pre build steps');
output += await CustomWorkflow.runCustomJob(
CloudRunner.buildParameters.preBuildSteps,
cloudRunnerStepState.environment,
cloudRunnerStepState.secrets,
);
if (!CloudRunner.buildParameters.isCliMode) core.endGroup();
}
output += CloudRunnerCustomSteps.RunPreBuildSteps(cloudRunnerStepState);
CloudRunnerLogger.logWithTime('Configurable pre build step(s) time');
if (!CloudRunner.buildParameters.isCliMode) core.startGroup('build');
@ -71,15 +64,7 @@ export class BuildAutomationWorkflow implements WorkflowInterface {
if (!CloudRunner.buildParameters.isCliMode) core.endGroup();
CloudRunnerLogger.logWithTime('Build time');
if (CloudRunner.buildParameters.postBuildSteps !== '') {
if (!CloudRunner.buildParameters.isCliMode) core.startGroup('post build steps');
output += await CustomWorkflow.runCustomJob(
CloudRunner.buildParameters.postBuildSteps,
cloudRunnerStepState.environment,
cloudRunnerStepState.secrets,
);
if (!CloudRunner.buildParameters.isCliMode) core.endGroup();
}
output += CloudRunnerCustomSteps.RunPostBuildSteps(cloudRunnerStepState);
CloudRunnerLogger.logWithTime('Configurable post build step(s) time');
if (CloudRunnerOptions.retainWorkspaces) {

View File

@ -1,38 +1,32 @@
import CloudRunnerLogger from '../services/cloud-runner-logger';
import CloudRunnerSecret from '../services/cloud-runner-secret';
import { CloudRunnerFolders } from '../services/cloud-runner-folders';
import YAML from 'yaml';
import { CloudRunner, Input } from '../..';
import { CloudRunner } from '../..';
import CloudRunnerEnvironmentVariable from '../services/cloud-runner-environment-variable';
import { CloudRunnerCustomSteps, CustomStep } from '../services/cloud-runner-custom-steps';
export class CustomWorkflow {
public static async runCustomJobFromString(
buildSteps: string,
environmentVariables: CloudRunnerEnvironmentVariable[],
secrets: CloudRunnerSecret[],
): Promise<string> {
return await CustomWorkflow.runCustomJob(
CloudRunnerCustomSteps.ParseSteps(buildSteps),
environmentVariables,
secrets,
);
}
public static async runCustomJob(
buildSteps,
buildSteps: CustomStep[],
environmentVariables: CloudRunnerEnvironmentVariable[],
secrets: CloudRunnerSecret[],
) {
try {
CloudRunnerLogger.log(`Cloud Runner is running in custom job mode`);
if (CloudRunner.buildParameters.cloudRunnerIntegrationTests) {
CloudRunnerLogger.log(`Parsing build steps: ${buildSteps}`);
}
try {
buildSteps = YAML.parse(buildSteps);
} catch (error) {
CloudRunnerLogger.log(`failed to parse a custom job "${buildSteps}"`);
throw error;
}
let output = '';
for (const step of buildSteps) {
const stepSecrets: CloudRunnerSecret[] = step.secrets.map((x) => {
const secret: CloudRunnerSecret = {
ParameterKey: x.name,
EnvironmentVariable: Input.ToEnvVarFormat(x.name),
ParameterValue: x.value,
};
return secret;
});
output += await CloudRunner.Provider.runTask(
CloudRunner.buildParameters.buildGuid,
step['image'],
@ -40,7 +34,7 @@ export class CustomWorkflow {
`/${CloudRunnerFolders.buildVolumeFolder}`,
`/${CloudRunnerFolders.projectPathAbsolute}/`,
environmentVariables,
[...secrets, ...stepSecrets],
[...secrets, ...step.secrets],
);
}

View File

@ -8,7 +8,7 @@ export class WorkflowCompositionRoot implements WorkflowInterface {
async run(cloudRunnerStepState: CloudRunnerStepState) {
try {
if (CloudRunner.buildParameters.customJob !== '') {
return await CustomWorkflow.runCustomJob(
return await CustomWorkflow.runCustomJobFromString(
CloudRunner.buildParameters.customJob,
cloudRunnerStepState.environment,
cloudRunnerStepState.secrets,