Skip to content

Commit bcba094

Browse files
committed
Added ability to for plugins to request elevated sudo privileges
1 parent 3bef8da commit bcba094

10 files changed

Lines changed: 144 additions & 29 deletions

File tree

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
"ajv": "^8.12.0",
1212
"ajv-formats": "^3.0.1",
1313
"chalk": "^5.3.0",
14-
"codify-schemas": "1.0.32",
14+
"codify-schemas": "1.0.37",
1515
"debug": "^4.3.4",
1616
"ink": "^4.4.1",
1717
"parse-json": "^8.1.0",

src/common/base-command.ts

Lines changed: 24 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,23 @@
11
import { Command, Flags } from '@oclif/core';
2-
import { Reporter, ReporterFactory, ReporterType } from '../ui/reporters/reporter.js';
32
import { FlagOutput } from '@oclif/core/lib/interfaces/parser.js';
4-
import createDebug from 'debug';
53
import chalk from 'chalk';
4+
import createDebug from 'debug';
5+
6+
import { ctx, Event } from '../events/context.js';
7+
import { Reporter, ReporterFactory, ReporterType } from '../ui/reporters/reporter.js';
68

79
export abstract class BaseCommand extends Command {
810

911
static enableJsonFlag = true;
1012
static baseFlags = {
13+
'debug': Flags.boolean(),
1114
'output': Flags.option({
12-
default: 'default',
1315
char: 'o',
16+
default: 'default',
1417
options: ['plain', 'default', 'debug', 'json'],
15-
})(),
16-
'debug': Flags.boolean()
18+
})()
1719
}
20+
1821
protected reporter!: Reporter;
1922

2023
public async init(): Promise<void> {
@@ -27,6 +30,11 @@ export abstract class BaseCommand extends Command {
2730

2831
const reporterType = this.getReporterType(flags);
2932
this.reporter = ReporterFactory.create(reporterType)
33+
34+
ctx.on(Event.SUDO_REQUEST, async (pluginName: string, command: string) => {
35+
await this.reporter.promptSudo(pluginName, command);
36+
ctx.sudoRequestGranted(pluginName);
37+
});
3038
}
3139

3240
protected async catch(err: Error): Promise<void> {
@@ -48,14 +56,21 @@ export abstract class BaseCommand extends Command {
4856

4957
if (flags.output) {
5058
switch (flags.output) {
51-
case 'debug':
59+
case 'debug': {
5260
return ReporterType.DEBUG;
53-
case 'json':
61+
}
62+
63+
case 'json': {
5464
return ReporterType.JSON;
55-
case 'plain':
65+
}
66+
67+
case 'plain': {
5668
return ReporterType.PLAIN;
57-
case 'default':
69+
}
70+
71+
case 'default': {
5872
return ReporterType.DEFAULT;
73+
}
5974
}
6075
}
6176

src/entities/project-config.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { ProjectConfig } from './project-config';
1+
import { ProjectConfig } from './project-config.js';
22
import { describe, expect, it } from 'vitest';
33

44
describe('Parser: project entity tests', () => {

src/events/context.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ export enum Event {
1111
PROCESS_FINISH = 'process_finish',
1212
SUB_PROCESS_START = 'sub_process_start',
1313
SUB_PROCESS_FINISH = 'sub_process_finish',
14+
SUDO_REQUEST = 'sudo_request',
15+
SUDO_REQUEST_GRANTED = 'sudo_request_granted',
1416
}
1517

1618
export enum ProcessName {
@@ -77,6 +79,14 @@ export const ctx = new class {
7779
this.emitter.emit(Event.SUB_PROCESS_FINISH, name, additionalName);
7880
}
7981

82+
sudoRequested(pluginName: string, command: string) {
83+
this.emitter.emit(Event.SUDO_REQUEST, pluginName, command);
84+
}
85+
86+
sudoRequestGranted(pluginName: string) {
87+
this.emitter.emit(Event.SUDO_REQUEST_GRANTED, pluginName);
88+
}
89+
8090
async subprocess<T>(name: string, run: () => Promise<T>): Promise<T> {
8191
this.emitter.emit(Event.SUB_PROCESS_START, name);
8292
const result = await run();

src/plugins/plugin-process.ts

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
1-
import { IpcMessage, IpcMessageSchema } from 'codify-schemas';
1+
import { IpcMessage, IpcMessageSchema, MessageCmd, SudoRequestData, SudoRequestDataSchema } from 'codify-schemas';
22
import { ChildProcess, fork } from 'node:child_process';
33
import { createRequire } from 'node:module';
44

5-
import { ctx } from '../events/context.js';
5+
import { ctx, Event } from '../events/context.js';
66
import { ajv } from '../utils/ajv.js';
77
import { PluginMessage } from './message.js';
88

99
const ipcMessageValidator = ajv.compile(IpcMessageSchema);
10+
const sudoRequestValidator = ajv.compile(SudoRequestDataSchema);
11+
1012

1113
type Resolve<T> = (value: T) => void;
1214
type Reject = (reason?: Error) => void;
@@ -30,7 +32,7 @@ export class PluginProcess {
3032
pluginPath,
3133
[],
3234
{
33-
env: { ...process.env, FORCE_COLOR: '1', DEBUG_COLORS: '1' },
35+
env: { ...process.env, DEBUG_COLORS: '1', FORCE_COLOR: '1' },
3436
silent: true,
3537
...(isTypescript && { execArgv: ['--import', 'tsx'] }),
3638
},
@@ -41,6 +43,7 @@ export class PluginProcess {
4143
_process.on('exit', (code) => {
4244
throw new Error(`Plugin ${this.name} exited with code ${code}`);
4345
})
46+
this.handleSudoRequests(_process);
4447

4548
return new PluginProcess(_process);
4649
}
@@ -67,12 +70,37 @@ export class PluginProcess {
6770
try {
6871
const require = createRequire(import.meta.url);
6972
require.resolve('tsx');
70-
} catch (e) {
73+
} catch {
7174
return false;
7275
}
7376

7477
return true;
7578
}
79+
80+
private static handleSudoRequests(process: ChildProcess) {
81+
process.on('message', (message) => {
82+
if (!ipcMessageValidator(message)) {
83+
throw new Error(`Invalid message from plugin. ${JSON.stringify(message, null, 2)}`);
84+
}
85+
86+
if (message.cmd === MessageCmd.SUDO_REQUEST) {
87+
const { data } = message;
88+
if (!sudoRequestValidator(data)) {
89+
throw new Error(`Invalid sudo request from plugin ${this.name}. ${JSON.stringify(sudoRequestValidator.errors, null, 2)}`);
90+
}
91+
92+
ctx.sudoRequested(this.name, (data as unknown as SudoRequestData).command);
93+
}
94+
})
95+
ctx.on(Event.SUDO_REQUEST_GRANTED, (pluginName) => {
96+
if (pluginName === this.name) {
97+
process.send({
98+
cmd: resultFunctionName(MessageCmd.SUDO_REQUEST),
99+
data: {}
100+
})
101+
}
102+
})
103+
}
76104
}
77105

78106
class SendMessageForResultHandler {

src/ui/components/default-component.tsx

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ export function DefaultComponent(props: {
1717
const [state, setState] = useState(RenderState.GENERATING_PLAN);
1818
const [progressState, setProgressState] = useState(null as ProgressState | null);
1919
const [plan, setPlan] = useState(null as PlanResponseData[] | null);
20+
const [isCleared, setIsCleared] = useState(false);
2021

2122
// Use layoutEffect runs before the first render, whereas useEffect runs after
2223
useLayoutEffect(() => {
@@ -39,11 +40,21 @@ export function DefaultComponent(props: {
3940
emitter.on(RenderEvent.PROGRESS_UPDATE, (state: ProgressState) => {
4041
setProgressState(structuredClone(state));
4142
});
43+
44+
emitter.on(RenderEvent.CLEAR, () => {
45+
setIsCleared(true);
46+
});
47+
48+
emitter.on(RenderEvent.UNCLEAR, () => {
49+
console.log('set unclear')
50+
setIsCleared(false);
51+
});
52+
4253
}, []);
4354

4455
return <Box flexDirection="column">
4556
{
46-
([RenderState.APPLYING, RenderState.GENERATING_PLAN].includes(state)) && progressState && (
57+
([RenderState.APPLYING, RenderState.GENERATING_PLAN].includes(state)) && progressState && !isCleared && (
4758
<ProgressDisplay progress={progressState}/>
4859
)
4960
}
@@ -63,5 +74,13 @@ export function DefaultComponent(props: {
6374
</Box>
6475
)
6576
}
77+
{/* { */}
78+
{/* showSudoPrompt && ( */}
79+
{/* <Box flexDirection='column'> */}
80+
{/* <Text>Password:</Text> */}
81+
{/* <PasswordInput onSubmit={(value) => emitter.emit(RenderEvent.PROMPT_SUDO_RESULT, value)}/> */}
82+
{/* </Box> */}
83+
{/* ) */}
84+
{/* } */}
6685
</Box>
6786
}

src/ui/reporters/debug-reporter.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
1+
import chalk from 'chalk';
12
import { PlanResponseData } from 'codify-schemas';
2-
import readline from 'node:readline';
33
import createDebug, { Debugger } from 'debug';
4+
import { execSync } from 'node:child_process';
5+
import readline from 'node:readline';
46

57
import { ctx, Event } from '../../events/context.js';
68
import { Reporter } from './reporter.js';
@@ -23,6 +25,11 @@ export class DebugReporter implements Reporter {
2325
ctx.on(Event.SUB_PROCESS_FINISH, (name) => debug(name))
2426
}
2527

28+
async promptSudo(pluginName: string, command: string): Promise<void> {
29+
console.log(chalk.blue(`Plugin: ${pluginName} requires root access to run command: '${command}'`));
30+
execSync('sudo -v');
31+
}
32+
2633
async promptApplyConfirmation(): Promise<boolean> {
2734
const response = await new Promise((resolve) => {
2835
this.rl.question('Is this okay?\n', (answer) => resolve(answer));

src/ui/reporters/default-reporter.tsx

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
1+
import chalk from 'chalk';
12
import { PlanResponseData } from 'codify-schemas';
23
import { render } from 'ink';
4+
import { execSync } from 'node:child_process';
35
import { EventEmitter } from 'node:events';
46
import React from 'react';
57

@@ -10,8 +12,8 @@ import { DisplayPlanStateTransition, RenderEvent, RenderState, Reporter } from '
1012

1113
const ProgressLabelMapping = {
1214
[ProcessName.APPLY]: 'Codify apply',
13-
[ProcessName.UNINSTALL]: 'Codify uninstall',
1415
[ProcessName.PLAN]: 'Codify plan',
16+
[ProcessName.UNINSTALL]: 'Codify uninstall',
1517
[SubProcessName.APPLYING_RESOURCE]: 'Applying resource',
1618
[SubProcessName.GENERATE_PLAN]: 'Refresh states and generating plan',
1719
[SubProcessName.INITIALIZE_PLUGINS]: 'Initializing plugins',
@@ -25,7 +27,7 @@ export class DefaultReporter implements Reporter {
2527
private progressState: ProgressState | null = null
2628

2729
constructor() {
28-
render(<DefaultComponent emitter={this.renderEmitter}/>)
30+
render(<DefaultComponent emitter={this.renderEmitter}/>);
2931

3032
ctx.on(Event.OUTPUT, (args) => this.log(args));
3133
ctx.on(Event.PROCESS_START, (name) => this.onProcessStartEvent(name))
@@ -34,6 +36,21 @@ export class DefaultReporter implements Reporter {
3436
ctx.on(Event.SUB_PROCESS_FINISH, (name, additionalName) => this.onSubprocessFinishEvent(name, additionalName))
3537
}
3638

39+
async promptSudo(pluginName: string, command: string): Promise<void> {
40+
console.log(chalk.blue(`Plugin: ${pluginName} requires root access to run command: '${command}'`));
41+
42+
// The sudo prompt and the inkjs renderer like to conflict when rendered together.
43+
// Clear the process bar while showing sudo.
44+
this.renderEmitter.emit(RenderEvent.CLEAR);
45+
46+
// We need to sleep for 200ms here to wait for ink.js to un-render the progress bar.
47+
// Ink renders asynchronously so the output is not cleared right away
48+
await new Promise((resolve) => setTimeout(resolve, 200));
49+
50+
execSync('sudo -v')
51+
this.renderEmitter.emit(RenderEvent.UNCLEAR);
52+
}
53+
3754
displayPlan(plan: PlanResponseData[]): void {
3855
this.progressState = null;
3956

src/ui/reporters/plain-reporter.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1+
import chalk from 'chalk';
12
import { PlanResponseData } from 'codify-schemas';
3+
import { execSync } from 'node:child_process';
24
import readline from 'node:readline';
35

46
import { ctx, Event } from '../../events/context.js';
@@ -15,6 +17,11 @@ export class PlainReporter implements Reporter {
1517
ctx.on(Event.SUB_PROCESS_FINISH, (name) => console.log(name))
1618
}
1719

20+
async promptSudo(pluginName: string, command: string): Promise<void> {
21+
console.log(chalk.blue(`Plugin: ${pluginName} requires root access to run command: '${command}'`));
22+
execSync('sudo -v');
23+
}
24+
1825
async promptApplyConfirmation(): Promise<boolean> {
1926
const response = await new Promise((resolve) => {
2027
this.rl.question('Is this okay?\n', (answer) => resolve(answer));

src/ui/reporters/reporter.ts

Lines changed: 22 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,16 @@
11
import { PlanResponseData } from 'codify-schemas';
2+
23
import { DebugReporter } from './debug-reporter.js';
3-
import { PlainReporter } from './plain-reporter.js';
44
import { DefaultReporter } from './default-reporter.js';
5+
import { PlainReporter } from './plain-reporter.js';
56

67
export enum RenderEvent {
78
LOG = 'log',
89
PROGRESS_UPDATE = 'progressUpdate',
910
PROMPT_RESULT = 'promptResult',
10-
STATE_TRANSITION = 'stateTransition'
11+
CLEAR = 'promptSudo',
12+
UNCLEAR = 'promptSudoResult',
13+
STATE_TRANSITION = 'stateTransition',
1114
}
1215

1316
/**
@@ -32,6 +35,8 @@ export interface Reporter {
3235
displayPlan(plan: PlanResponseData[]): void
3336

3437
promptApplyConfirmation(): Promise<boolean>
38+
39+
promptSudo(pluginName: string, command: string): Promise<void>;
3540
}
3641

3742
export enum ReporterType {
@@ -41,17 +46,24 @@ export enum ReporterType {
4146
JSON
4247
}
4348

44-
export class ReporterFactory {
45-
static create(type: ReporterType): Reporter {
49+
export const ReporterFactory = {
50+
create(type: ReporterType): Reporter {
4651
switch (type) {
47-
case ReporterType.DEBUG:
52+
case ReporterType.DEBUG: {
4853
return new DebugReporter();
49-
case ReporterType.PLAIN:
54+
}
55+
56+
case ReporterType.PLAIN: {
5057
return new PlainReporter();
51-
case ReporterType.JSON:
58+
}
59+
60+
case ReporterType.JSON: {
5261
return new DefaultReporter();
53-
default:
62+
}
63+
64+
default: {
5465
return new DefaultReporter();
66+
}
5567
}
56-
}
57-
}
68+
},
69+
};

0 commit comments

Comments
 (0)