Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions src/commands/encoding/outputs/create/gcs-service-account.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import {Flags} from '@oclif/core';
import {readFileSync} from 'node:fs';
import {GcsServiceAccountOutput} from '@bitmovin/api-sdk';
import {BaseCommand} from '../../../../lib/base-command.js';

export default class EncodingOutputCreateGcsServiceAccount extends BaseCommand {
static override description =
'Create a service-account-based GCS output. Reads the JSON key file directly so credentials never appear in shell history.';

static override flags = {
...BaseCommand.baseFlags,
name: Flags.string({description: 'Output name', required: true}),
bucket: Flags.string({description: 'GCS bucket name', required: true}),
'service-account-key-file': Flags.string({
description: 'Path to the service account JSON key file',
required: true,
}),
'cloud-region': Flags.string({
description: 'GCS region the bucket is located in (e.g. EUROPE_WEST_1)',
}),
};

static override examples = [
'bitmovin encoding outputs create gcs-service-account --name my-output --bucket my-bucket --service-account-key-file ./sa-key.json',
'bitmovin encoding outputs create gcs-service-account --name my-output --bucket my-bucket --service-account-key-file ./sa-key.json --cloud-region EUROPE_WEST_1',
];

async run(): Promise<void> {
const {flags} = await this.parse(EncodingOutputCreateGcsServiceAccount);

let credentials: string;
try {
credentials = readFileSync(flags['service-account-key-file'], 'utf-8');
} catch (e) {
this.error(`Could not read service account key file: ${(e as Error).message}`);
}

// The JSON file should at least be parsable JSON; surface a clear error
// here rather than letting the API reject it later.
try {
JSON.parse(credentials);
} catch {
this.error(
`Service account key file ${flags['service-account-key-file']} is not valid JSON.`,
);
}

const output = new GcsServiceAccountOutput({
name: flags.name,
bucketName: flags.bucket,
serviceAccountCredentials: credentials,
...(flags['cloud-region'] && {cloudRegion: flags['cloud-region'] as never}),
});

const result = await (
await this.getApi()
).encoding.outputs.gcsServiceAccount.create(output);
this.log(`Output created: ${result.id}`);
await this.outputData(result);
}
}
93 changes: 92 additions & 1 deletion test/commands/encoding-inputs-outputs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ const inputCreateHttpsMock = vi.fn().mockResolvedValue({id: 'in-new-https', name
const inputDeleteS3Mock = vi.fn().mockResolvedValue({});
const outputCreateS3Mock = vi.fn().mockResolvedValue({id: 'out-new-s3', name: 'New S3 Out'});
const outputCreateGcsMock = vi.fn().mockResolvedValue({id: 'out-new-gcs', name: 'New GCS Out'});
const outputCreateGcsSaMock = vi
.fn()
.mockResolvedValue({id: 'out-new-gcs-sa', name: 'New GCS SA Out', type: 'GCS_SERVICE_ACCOUNT'});
const outputDeleteS3Mock = vi.fn().mockResolvedValue({});

vi.mock('../../src/lib/client.js', () => ({
Expand Down Expand Up @@ -70,7 +73,7 @@ vi.mock('../../src/lib/client.js', () => ({
delete: vi.fn(),
},
azure: {list: async () => ({items: []}), delete: vi.fn()},
gcsServiceAccount: {delete: vi.fn()},
gcsServiceAccount: {create: outputCreateGcsSaMock, delete: vi.fn()},
ftp: {delete: vi.fn()},
sftp: {delete: vi.fn()},
akamaiNetstorage: {delete: vi.fn()},
Expand Down Expand Up @@ -237,3 +240,91 @@ describe('encoding outputs create', () => {
expect(data.id).toBe('out-new-gcs');
});
});

describe('encoding outputs create gcs-service-account', () => {
const fs = require('node:fs') as typeof import('node:fs');
const os = require('node:os') as typeof import('node:os');
const path = require('node:path') as typeof import('node:path');

function writeKey(content: string): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'bm-cli-sa-'));
const file = path.join(dir, 'sa.json');
fs.writeFileSync(file, content);
return file;
}

it('creates an output reading the SA JSON key file', async () => {
const keyFile = writeKey(JSON.stringify({type: 'service_account', project_id: 'p'}));
const cap = captureStdout();
const {default: Cmd} = await import(
'../../src/commands/encoding/outputs/create/gcs-service-account.js'
);
await Cmd.run([
'--name',
'My GCS SA',
'--bucket',
'my-bucket',
'--service-account-key-file',
keyFile,
'--cloud-region',
'EUROPE_WEST_1',
'--json',
]);
cap.restore();
expect(outputCreateGcsSaMock).toHaveBeenCalled();
const arg = outputCreateGcsSaMock.mock.calls[0][0];
expect(arg.name).toBe('My GCS SA');
expect(arg.bucketName).toBe('my-bucket');
expect(typeof arg.serviceAccountCredentials).toBe('string');
// Confirm the credentials are passed verbatim (not parsed/re-stringified).
expect(JSON.parse(arg.serviceAccountCredentials).type).toBe('service_account');
expect(arg.cloudRegion).toBe('EUROPE_WEST_1');
const data = JSON.parse(cap.output());
expect(data.id).toBe('out-new-gcs-sa');
});

it('rejects an invalid JSON key file before calling the API', async () => {
const keyFile = writeKey('not actually json');
outputCreateGcsSaMock.mockClear();
const cap = captureStdout();
const errCap = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
const {default: Cmd} = await import(
'../../src/commands/encoding/outputs/create/gcs-service-account.js'
);
await expect(
Cmd.run([
'--name',
'x',
'--bucket',
'b',
'--service-account-key-file',
keyFile,
]),
).rejects.toThrow(/EEXIT|not valid JSON/);
cap.restore();
errCap.mockRestore();
expect(outputCreateGcsSaMock).not.toHaveBeenCalled();
});

it('rejects a missing key file', async () => {
outputCreateGcsSaMock.mockClear();
const cap = captureStdout();
const errCap = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
const {default: Cmd} = await import(
'../../src/commands/encoding/outputs/create/gcs-service-account.js'
);
await expect(
Cmd.run([
'--name',
'x',
'--bucket',
'b',
'--service-account-key-file',
'/no/such/path.json',
]),
).rejects.toThrow(/EEXIT|Could not read/);
cap.restore();
errCap.mockRestore();
expect(outputCreateGcsSaMock).not.toHaveBeenCalled();
});
});
Loading