Skip to content
Draft
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
10 changes: 10 additions & 0 deletions app/guid-node/binderhub/-components/build-console/component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,16 @@ export default class BuildConsole extends Component {

notAuthorized: boolean = false;

copyDefaultStorage?: boolean;

pathsYamlIsCustom?: boolean;

userHasWritePermission?: boolean;

toggleCopyDefaultStorage?: (event: Event) => void;

openPathsYamlResetConfirm?: () => void;

@requiredAction renewToken!: (binderhubUrl: string) => void;

@requiredAction requestBuild!: (
Expand Down
20 changes: 20 additions & 0 deletions app/guid-node/binderhub/-components/build-console/styles.scss
Original file line number Diff line number Diff line change
Expand Up @@ -53,3 +53,23 @@
.build_launch_button {
margin-left: auto;
}

.paths_toggle {
margin: 1.5em 0;
}

.paths_toggle__label {
display: flex;
align-items: center;
gap: 0.5em;
margin-bottom: 0;
}

.paths_toggle__label--disabled {
color: #999;
cursor: not-allowed;
}

.paths_toggle__hint {
margin: 0.5em 0;
}
28 changes: 27 additions & 1 deletion app/guid-node/binderhub/-components/build-console/template.hbs
Original file line number Diff line number Diff line change
@@ -1,7 +1,33 @@
<h3>
{{t 'binderhub.deployment.header'}}
</h3>
{{t 'binderhub.deployment.description'}}
<div local-class='paths_toggle'>
<label local-class='paths_toggle__label {{if (or (not this.userHasWritePermission) this.pathsYamlIsCustom) 'paths_toggle__label--disabled'}}'>
<Input
@type='checkbox'
@checked={{this.copyDefaultStorage}}
@change={{this.toggleCopyDefaultStorage}}
disabled={{or (not this.userHasWritePermission) this.pathsYamlIsCustom}}
data-test-copy-default-storage
/>
{{t 'binderhub.deployment.copy_default_storage_checkbox'}}
</label>
{{#if this.pathsYamlIsCustom}}
<p local-class='paths_toggle__hint'>
{{t 'binderhub.deployment.copy_default_storage_disabled_hint'}}
</p>
{{#if this.userHasWritePermission}}
<button
type='button'
class='btn btn-default'
data-test-reset-paths-yaml
{{action this.openPathsYamlResetConfirm}}
>
{{t 'binderhub.deployment.reset_paths_yaml'}}
</button>
{{/if}}
{{/if}}
</div>
<div local-class='build_launcher'>
<BsButton
data-test-binderhub-launch
Expand Down
198 changes: 198 additions & 0 deletions app/guid-node/binderhub/-components/project-editor/component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ import CurrentUser from 'ember-osf-web/services/current-user';
import { WaterButlerFile } from 'ember-osf-web/utils/waterbutler/base';
import md5 from 'js-md5';

const PATHS_YAML_FILENAME = 'paths.yaml';
const MINIMAL_PATHS_YAML_CONTENT = 'override: true\npaths: []';

export const REPO2DOCKER_IMAGE_ID = '#repo2docker';

enum DockerfileProperty {
Expand All @@ -33,6 +36,12 @@ enum DockerfileProperty {
NoChanges,
}

enum PathsYamlState {
DEFAULT = 'default', // paths.yaml does not exist, copy default storage
NO_COPY = 'no_copy', // minimal paths.yaml exists, do not copy
CUSTOM = 'custom', // custom paths.yaml exists, UI disabled
}

function imageFromCustomBaseImageModel(model: CustomBaseImageModel) {
return {
url: model.imageReference,
Expand Down Expand Up @@ -190,6 +199,20 @@ export default class ProjectEditor extends Component {

configFolder: WaterButlerFile = this.configFolder;

pathsYamlState: PathsYamlState = PathsYamlState.DEFAULT;

@computed('pathsYamlState')
get copyDefaultStorage(): boolean {
return this.pathsYamlState === PathsYamlState.DEFAULT;
}

@computed('pathsYamlState')
get pathsYamlIsCustom(): boolean {
return this.pathsYamlState === PathsYamlState.CUSTOM;
}

showPathsYamlResetConfirm: boolean = false;

initialCustomBaseImages: CustomBaseImageModel[] = [];

dyCustomBaseImageModels: CustomBaseImageModel[] = [];
Expand All @@ -208,6 +231,8 @@ export default class ProjectEditor extends Component {

postBuildModel: WaterButlerFile | null = this.postBuildModel;

pathsYamlModel: WaterButlerFile | null = this.pathsYamlModel;

showDirtyFragileFileConfirmDialog = false;

imageSelecting = false;
Expand All @@ -230,6 +255,8 @@ export default class ProjectEditor extends Component {

postBuild: string | undefined = undefined;

pathsYaml: string | undefined = undefined;

editingPostBuild: string | undefined = undefined;

editingPackage: string | undefined = undefined;
Expand Down Expand Up @@ -394,6 +421,12 @@ export default class ProjectEditor extends Component {
return this.verifyHashHeader(content);
}

@computed('pathsYaml')
get pathsYamlManuallyChanged() {
const content = this.get('pathsYaml');
return this.verifyHashHeader(content);
}

get requirementsManuallyChanged() {
// Currently, requirements.txt is never created.
// To maintain compatibility with projects created in previous versions
Expand Down Expand Up @@ -1156,6 +1189,59 @@ export default class ProjectEditor extends Component {
return name;
}

@computed('pathsYaml')
get pathsYamlContent(): { override: boolean; paths: string[] } | null {
if (this.pathsYamlManuallyChanged) {
return null;
}
const content = this.get('pathsYaml');
if (content === undefined || content.length === 0) {
return null;
}
const lines = content.split('\n');
let override = false;
const paths: string[] = [];
let inPathsSection = false;

for (const line of lines) {
if (line.trim().startsWith('#')) {
continue;
}
// Check for "override: true"
const overrideMatch = line.match(/^\s*override\s*:\s*(true|false)\s*$/);
if (overrideMatch) {
override = overrideMatch[1] === 'true';
continue;
}
// Check for "paths:" section
const pathsSectionMatch = line.match(/^\s*paths\s*:\s*$/);
if (pathsSectionMatch) {
inPathsSection = true;
continue;
}
// Check for inline empty array "paths: []"
const inlinePathsMatch = line.match(/^\s*paths\s*:\s*\[\s*\]\s*$/);
if (inlinePathsMatch) {
// paths is empty array
continue;
}
// Parse items in paths array
if (inPathsSection) {
const itemMatch = line.match(/^\s*-\s+(.+)\s*$/);
if (itemMatch) {
paths.push(itemMatch[1]);
continue;
}
// If we hit a non-indented line, we've exited the paths section
if (line.match(/^\S/)) {
inPathsSection = false;
}
}
}

return { override, paths };
}

@computed('requirements')
get requirementsLines() {
if (this.requirementsManuallyChanged) {
Expand Down Expand Up @@ -1368,6 +1454,7 @@ export default class ProjectEditor extends Component {
const confFiles = this.configurationFiles;
const tasks = confFiles.map(file => this.loadCurrentFile(file, files));
await Promise.all(tasks);
await this.loadPathsYaml(files);
}

async saveCurrentFile(
Expand Down Expand Up @@ -1624,6 +1711,50 @@ export default class ProjectEditor extends Component {
);
}

@action
toggleCopyDefaultStorage(this: ProjectEditor, event: Event) {
const node = this.get('node');
if (!node || !node.userHasWritePermission || this.pathsYamlIsCustom) {
return;
}
const target = event.target as HTMLInputElement;
const previous = this.pathsYamlState;
const newState = target.checked ? PathsYamlState.DEFAULT : PathsYamlState.NO_COPY;
this.set('pathsYamlState', newState);
later(async () => {
try {
await this.persistPathsYamlPreference();
this.pageCleanser();
} catch (exception) {
this.set('pathsYamlState', previous);
this.onError(exception, this.intl.t('binderhub.error.modify_files_error'));
}
}, 0);
}

@action
openPathsYamlResetConfirm(this: ProjectEditor) {
this.set('showPathsYamlResetConfirm', true);
}

@action
cancelPathsYamlReset(this: ProjectEditor) {
this.set('showPathsYamlResetConfirm', false);
}

@action
resetPathsYaml(this: ProjectEditor) {
this.set('showPathsYamlResetConfirm', false);
later(async () => {
try {
await this.performPathsYamlReset();
this.pageCleanser();
} catch (exception) {
this.onError(exception, this.intl.t('binderhub.error.modify_files_error'));
}
}, 0);
}

@action
editDockerfileContent(this: ProjectEditor, event: { target: HTMLInputElement }) {
this.set('editingDockerfileContent', event.target.value);
Expand Down Expand Up @@ -1728,4 +1859,71 @@ export default class ProjectEditor extends Component {
modifyCustomBaseImageModelForLocale(model: CustomBaseImageModel) {
return this.modifyImageForLocale(imageFromCustomBaseImageModel(model));
}

private getHashedMinimalPathsYaml(): string {
const content = MINIMAL_PATHS_YAML_CONTENT;
const checksum = md5(content.trim());
return `# rdm-binderhub:hash:${checksum}\n${content}`;
}

private async loadPathsYaml(files: WaterButlerFile[] | null) {
const pathsFile = await this.getFile(PATHS_YAML_FILENAME, files);
if (!pathsFile) {
this.set('pathsYamlModel', null);
this.set('pathsYaml', '');
this.set('pathsYamlState', PathsYamlState.DEFAULT);
return;
}
const content = (await pathsFile.getContents()).toString();
this.set('pathsYamlModel', pathsFile);
this.set('pathsYaml', content);

// Use computed property to check if manually changed (avoid duplicate hash verification)
if (this.pathsYamlManuallyChanged) {
this.set('pathsYamlState', PathsYamlState.CUSTOM);
return;
}

// Hash is valid - verify content structure is minimal paths.yaml
const parsed = this.pathsYamlContent;
if (!parsed) {
throw new EmberError('Invalid paths.yaml: hash is valid but failed to parse content');
}
if (!parsed.override || parsed.paths.length !== 0) {
throw new EmberError(
'Invalid paths.yaml: hash is valid but content does not match minimal template',
);
}
this.set('pathsYamlState', PathsYamlState.NO_COPY);
}

private async persistPathsYamlPreference() {
if (!this.configFolder) {
throw new EmberError('Illegal config');
}
const files = await this.getRootFiles(true);
const pathsFile = await this.getFile(PATHS_YAML_FILENAME, files);
if (this.pathsYamlState === PathsYamlState.DEFAULT) {
if (pathsFile) {
await pathsFile.delete();
}
} else {
const content = this.getHashedMinimalPathsYaml();
if (!pathsFile) {
await this.configFolder.createFile(PATHS_YAML_FILENAME, content);
} else {
await pathsFile.updateContents(content);
}
}
await this.loadPathsYaml(await this.getRootFiles(true));
}

private async performPathsYamlReset() {
const files = await this.getRootFiles(true);
const pathsFile = await this.getFile(PATHS_YAML_FILENAME, files);
if (pathsFile) {
await pathsFile.delete();
}
await this.loadPathsYaml(await this.getRootFiles(true));
}
}
32 changes: 32 additions & 0 deletions app/guid-node/binderhub/-components/project-editor/template.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -266,10 +266,42 @@
@beforeLaunch={{this.performDockerfileSave}}
@buildLog={{this.buildLog}}
@buildPhase={{this.buildPhase}}
@copyDefaultStorage={{this.copyDefaultStorage}}
@pathsYamlIsCustom={{this.pathsYamlIsCustom}}
@userHasWritePermission={{this.node.userHasWritePermission}}
@toggleCopyDefaultStorage={{action this.toggleCopyDefaultStorage}}
@openPathsYamlResetConfirm={{action this.openPathsYamlResetConfirm}}
/>
{{/if}}
</div>

<BsModal
data-test-reset-paths-yaml-confirm-modal
@open={{this.showPathsYamlResetConfirm}}
@onHidden={{action this.cancelPathsYamlReset}}
as |modal|
>
<modal.header>
<h4>
{{t 'binderhub.deployment.reset_paths_yaml_confirm_title'}}
</h4>
</modal.header>
<modal.body>
{{t 'binderhub.deployment.reset_paths_yaml_confirm_body'}}
</modal.body>
<modal.footer>
<BsButton @onClick={{action this.cancelPathsYamlReset}}>
{{t 'general.cancel'}}
</BsButton>
<BsButton
@onClick={{action this.resetPathsYaml}}
@type='danger'
>
{{t 'binderhub.deployment.reset_paths_yaml_confirm_ok'}}
</BsButton>
</modal.footer>
</BsModal>

<BsModal
data-test-reset-dirty-fragile-files-confirm-modal
@open={{this.showDirtyFragileFileConfirmDialog}}
Expand Down
Loading