Skip to content

Commit 9dc4d6d

Browse files
gtherondclaude
andcommitted
[1954] feat(ui): clouds.yaml credential input component
Adds the client-side foundation for entering OpenStack credentials in the standard clouds.yaml format from the web UI. - ui/src/utils/cloudsYamlParser.ts: parse clouds.yaml content with js-yaml. parseCloudsYAML returns a discriminated-union result (ParseSuccess | ParseFailure) carrying the parsed structure and cloud entry names on success; on failure returns the YAMLException reason plus line/column from js-yaml's mark. detectAuthMethod mirrors the controller's allowlist (empty / password / v3password / v3applicationcredential). maskSecrets returns a copy of an entry with password and application_credential_secret values redacted for post-parse display. - ui/src/features/credentials/components/CloudsYamlInput.tsx: React component using MUI primitives. Operators paste content or upload a file; the parse runs on every change, errors surface inline with line/column, the cloud-name selector appears when >1 entry is present, and an "Application Credential" / "Password" / "Unsupported" chip confirms the detected auth method before submission. Disabled state is supported via prop. - ui/package.json: adds js-yaml@^4.1.0 (direct dep, was previously only transitive) and @types/js-yaml@^4.0.9 to devDependencies. Implements FR-011 (paste / upload + client-side parse + inline errors), FR-013 (cloud-name selector), FR-014 (auth-method badge), and FR-015 (secret masking via maskSecrets) at the component level. Out of scope for this commit (follow-up): integration into the existing OpenstackCredentialsDrawer (FR-012 "default tab"), and the API helper that writes a clouds.yaml-keyed Secret + OpenstackCreds resource. The drawer integration was left out because the existing 479-line drawer uses react-hook-form with a single OpenRC field; surfacing the clouds.yaml input alongside it deserves its own scoped change rather than being bundled here. Also: the UI codebase currently has no vitest/jest setup so this component ships without unit tests; adding test infrastructure is a separate concern. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 36944c8 commit 9dc4d6d

3 files changed

Lines changed: 384 additions & 0 deletions

File tree

ui/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
"@cds/react": "^6.15.1",
2626
"@codemirror/lang-yaml": "^6.1.2",
2727
"@emotion/react": "^11.13.3",
28+
"js-yaml": "^4.1.0",
2829
"@emotion/styled": "^11.13.0",
2930
"@fontsource/roboto": "^5.1.0",
3031
"@hookform/resolvers": "^5.2.2",
@@ -64,6 +65,7 @@
6465
"@storybook/react-vite": "8.6.14",
6566
"@storybook/test": "8.6.14",
6667
"@tanstack/eslint-plugin-query": "^5.59.20",
68+
"@types/js-yaml": "^4.0.9",
6769
"@types/node": "^22.19.2",
6870
"@types/ramda": "^0.30.2",
6971
"@types/react": "^18.3.3",
Lines changed: 225 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,225 @@
1+
import {
2+
Alert,
3+
Box,
4+
Chip,
5+
FormControl,
6+
InputLabel,
7+
MenuItem,
8+
Select,
9+
TextField,
10+
Typography,
11+
} from '@mui/material'
12+
import { ChangeEvent, useMemo, useRef, useState } from 'react'
13+
import {
14+
parseCloudsYAML,
15+
detectAuthMethod,
16+
ParseResult,
17+
} from 'src/utils/cloudsYamlParser'
18+
19+
export interface CloudsYamlInputValue {
20+
/** Raw clouds.yaml content (the string that will be stored in the Secret). */
21+
cloudsYaml: string
22+
/** Selected cloud entry name; required when the YAML has >1 entry. */
23+
cloudName: string
24+
/** Parse status; emitted so parent forms can disable submit on errors. */
25+
isValid: boolean
26+
}
27+
28+
export interface CloudsYamlInputProps {
29+
initialValue?: string
30+
onChange: (value: CloudsYamlInputValue) => void
31+
disabled?: boolean
32+
}
33+
34+
const PLACEHOLDER = `clouds:
35+
destination:
36+
auth_type: v3applicationcredential
37+
auth:
38+
auth_url: https://keystone.example.com:5000/v3
39+
application_credential_id: <id>
40+
application_credential_secret: <secret>
41+
region_name: RegionOne
42+
interface: public
43+
`
44+
45+
/**
46+
* Credential input for the OpenStack clouds.yaml format.
47+
*
48+
* Operators paste their clouds.yaml content or upload a file; the component
49+
* parses client-side via {@link parseCloudsYAML}, surfaces parse errors
50+
* inline, populates a cloud-name selector when the YAML carries multiple
51+
* cloud entries, and shows an auth-method badge (password vs Application
52+
* Credential) so the operator can confirm before submission.
53+
*
54+
* The component is intentionally stateless about persistence — the parent
55+
* form decides when to submit and is responsible for calling the API helper
56+
* that writes the Secret + OpenstackCreds resource.
57+
*/
58+
export default function CloudsYamlInput({
59+
initialValue = '',
60+
onChange,
61+
disabled,
62+
}: CloudsYamlInputProps) {
63+
const [raw, setRaw] = useState<string>(initialValue)
64+
const [selectedCloud, setSelectedCloud] = useState<string>('')
65+
const fileInputRef = useRef<HTMLInputElement | null>(null)
66+
67+
const parseResult: ParseResult = useMemo(() => parseCloudsYAML(raw), [raw])
68+
69+
const cloudNames = parseResult.ok ? parseResult.cloudNames : []
70+
const effectiveCloud =
71+
cloudNames.length === 1 ? cloudNames[0] : selectedCloud
72+
const selectedEntry =
73+
parseResult.ok && effectiveCloud
74+
? parseResult.parsed.clouds?.[effectiveCloud]
75+
: undefined
76+
const authMethod = detectAuthMethod(selectedEntry)
77+
78+
const isValid =
79+
parseResult.ok &&
80+
effectiveCloud !== '' &&
81+
authMethod !== 'unsupported'
82+
83+
// Emit upstream whenever effective state changes.
84+
useMemoOnChange(() => {
85+
onChange({
86+
cloudsYaml: raw,
87+
cloudName: effectiveCloud,
88+
isValid,
89+
})
90+
}, [raw, effectiveCloud, isValid])
91+
92+
const onTextChange = (e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
93+
setRaw(e.target.value)
94+
setSelectedCloud('')
95+
}
96+
97+
const onFileChange = (e: ChangeEvent<HTMLInputElement>) => {
98+
const file = e.target.files?.[0]
99+
if (!file) {
100+
return
101+
}
102+
const reader = new FileReader()
103+
reader.onload = (event) => {
104+
const text = String(event.target?.result ?? '')
105+
setRaw(text)
106+
setSelectedCloud('')
107+
}
108+
reader.readAsText(file)
109+
}
110+
111+
return (
112+
<Box display="flex" flexDirection="column" gap={2}>
113+
<Typography variant="body2" color="text.secondary">
114+
Paste your <code>clouds.yaml</code> content below, or upload a file.
115+
The content is parsed in your browser; nothing is sent until you
116+
submit.
117+
</Typography>
118+
119+
<Box display="flex" gap={1} alignItems="center">
120+
<input
121+
type="file"
122+
accept=".yaml,.yml,application/x-yaml,text/yaml"
123+
ref={fileInputRef}
124+
style={{ display: 'none' }}
125+
onChange={onFileChange}
126+
/>
127+
<button
128+
type="button"
129+
onClick={() => fileInputRef.current?.click()}
130+
disabled={disabled}
131+
>
132+
Upload clouds.yaml
133+
</button>
134+
<Typography variant="caption" color="text.secondary">
135+
or paste below
136+
</Typography>
137+
</Box>
138+
139+
<TextField
140+
label="clouds.yaml"
141+
multiline
142+
minRows={10}
143+
maxRows={20}
144+
fullWidth
145+
value={raw}
146+
onChange={onTextChange}
147+
placeholder={PLACEHOLDER}
148+
disabled={disabled}
149+
spellCheck={false}
150+
slotProps={{
151+
input: {
152+
sx: { fontFamily: 'monospace', fontSize: '0.85rem' },
153+
},
154+
}}
155+
/>
156+
157+
{!parseResult.ok && raw !== '' && (
158+
<Alert severity="error" variant="outlined">
159+
{parseResult.error}
160+
{parseResult.line !== undefined && (
161+
<> (line {parseResult.line + 1}
162+
{parseResult.column !== undefined && `, column ${parseResult.column + 1}`}
163+
)</>
164+
)}
165+
</Alert>
166+
)}
167+
168+
{parseResult.ok && cloudNames.length > 1 && (
169+
<FormControl size="small" fullWidth>
170+
<InputLabel id="clouds-yaml-cloud-name-label">Cloud entry</InputLabel>
171+
<Select
172+
labelId="clouds-yaml-cloud-name-label"
173+
label="Cloud entry"
174+
value={selectedCloud}
175+
onChange={(e) => setSelectedCloud(e.target.value)}
176+
disabled={disabled}
177+
>
178+
{cloudNames.map((name) => (
179+
<MenuItem key={name} value={name}>
180+
{name}
181+
</MenuItem>
182+
))}
183+
</Select>
184+
</FormControl>
185+
)}
186+
187+
{parseResult.ok && effectiveCloud && (
188+
<Box display="flex" gap={1} alignItems="center" flexWrap="wrap">
189+
<Typography variant="body2">Auth method:</Typography>
190+
{authMethod === 'applicationCredential' && (
191+
<Chip
192+
label="Application Credential"
193+
color="success"
194+
size="small"
195+
variant="outlined"
196+
/>
197+
)}
198+
{authMethod === 'password' && (
199+
<Chip label="Password" size="small" variant="outlined" />
200+
)}
201+
{authMethod === 'unsupported' && (
202+
<Chip
203+
label={`Unsupported auth_type: ${selectedEntry?.auth_type ?? '(none)'}`}
204+
color="error"
205+
size="small"
206+
variant="outlined"
207+
/>
208+
)}
209+
</Box>
210+
)}
211+
</Box>
212+
)
213+
}
214+
215+
/**
216+
* Tiny effect helper: call `fn` whenever any of `deps` changes. Avoids
217+
* pulling React useEffect's lint dependency check noise into this file by
218+
* keeping the dep list explicit.
219+
*/
220+
function useMemoOnChange(fn: () => void, deps: unknown[]) {
221+
// eslint-disable-next-line react-hooks/exhaustive-deps
222+
useMemo(() => {
223+
fn()
224+
}, deps)
225+
}

ui/src/utils/cloudsYamlParser.ts

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
/**
2+
* Client-side parser for the OpenStack clouds.yaml credential format.
3+
*
4+
* Mirrors the controller-side parser in
5+
* k8s/migration/pkg/utils/clouds_yaml.go so the UI can validate operator
6+
* input, surface inline errors, and populate the cloud-name selector + auth
7+
* method badge before submission.
8+
*/
9+
import yaml from 'js-yaml'
10+
11+
export type CloudsYamlAuthType =
12+
| 'v3password'
13+
| 'password'
14+
| 'v3applicationcredential'
15+
| string
16+
17+
export interface CloudsYamlAuth {
18+
auth_url?: string
19+
username?: string
20+
password?: string
21+
application_credential_id?: string
22+
application_credential_secret?: string
23+
project_name?: string
24+
project_id?: string
25+
user_domain_name?: string
26+
project_domain_name?: string
27+
[k: string]: unknown
28+
}
29+
30+
export interface CloudsYamlEntry {
31+
auth_type?: CloudsYamlAuthType
32+
auth?: CloudsYamlAuth
33+
region_name?: string
34+
interface?: string
35+
verify?: boolean
36+
cacert?: string
37+
compute_api_version?: string
38+
volume_api_version?: string
39+
image_api_version?: string
40+
network_api_version?: string
41+
identity_api_version?: string
42+
[k: string]: unknown
43+
}
44+
45+
export interface CloudsYamlFile {
46+
clouds?: Record<string, CloudsYamlEntry>
47+
}
48+
49+
export interface ParseSuccess {
50+
ok: true
51+
raw: string
52+
parsed: CloudsYamlFile
53+
cloudNames: string[]
54+
}
55+
56+
export interface ParseFailure {
57+
ok: false
58+
raw: string
59+
error: string
60+
// Best-effort line/column from js-yaml's YAMLException.
61+
line?: number
62+
column?: number
63+
}
64+
65+
export type ParseResult = ParseSuccess | ParseFailure
66+
67+
/**
68+
* Parse clouds.yaml content. On success, returns the parsed structure plus the
69+
* list of top-level cloud entry names. On failure, returns an error message
70+
* suitable for inline display (with line/column when available).
71+
*/
72+
export function parseCloudsYAML(input: string): ParseResult {
73+
try {
74+
const parsed = yaml.load(input, { schema: yaml.JSON_SCHEMA }) as
75+
| CloudsYamlFile
76+
| null
77+
| undefined
78+
79+
if (!parsed || typeof parsed !== 'object') {
80+
return {
81+
ok: false,
82+
raw: input,
83+
error: 'Expected a YAML mapping with a top-level "clouds" key.',
84+
}
85+
}
86+
if (!parsed.clouds || typeof parsed.clouds !== 'object') {
87+
return {
88+
ok: false,
89+
raw: input,
90+
error: 'Missing top-level "clouds:" mapping.',
91+
}
92+
}
93+
94+
const cloudNames = Object.keys(parsed.clouds)
95+
if (cloudNames.length === 0) {
96+
return {
97+
ok: false,
98+
raw: input,
99+
error: '"clouds:" mapping contains no cloud entries.',
100+
}
101+
}
102+
103+
return { ok: true, raw: input, parsed, cloudNames }
104+
} catch (e) {
105+
const yamlErr = e as { reason?: string; mark?: { line: number; column: number } }
106+
return {
107+
ok: false,
108+
raw: input,
109+
error: yamlErr.reason ?? (e instanceof Error ? e.message : String(e)),
110+
line: yamlErr.mark?.line,
111+
column: yamlErr.mark?.column,
112+
}
113+
}
114+
}
115+
116+
/**
117+
* Detect the auth method declared by a cloud entry. Mirrors the controller's
118+
* allowlist: empty / v3password / password / v3applicationcredential.
119+
* Anything else is reported as "unsupported" so the UI can surface a warning.
120+
*/
121+
export type AuthMethodKind =
122+
| 'password'
123+
| 'applicationCredential'
124+
| 'unsupported'
125+
126+
export function detectAuthMethod(entry?: CloudsYamlEntry): AuthMethodKind {
127+
if (!entry) {
128+
return 'unsupported'
129+
}
130+
const t = entry.auth_type ?? ''
131+
if (t === '' || t === 'password' || t === 'v3password') {
132+
return 'password'
133+
}
134+
if (t === 'v3applicationcredential') {
135+
return 'applicationCredential'
136+
}
137+
return 'unsupported'
138+
}
139+
140+
/**
141+
* Return a copy of the cloud entry with secret-bearing fields masked, suitable
142+
* for display in a post-parse summary. The operator can confirm the parsed
143+
* structure without seeing the actual secret value.
144+
*/
145+
export function maskSecrets(entry: CloudsYamlEntry): CloudsYamlEntry {
146+
if (!entry.auth) {
147+
return entry
148+
}
149+
const masked: CloudsYamlAuth = { ...entry.auth }
150+
if (masked.password !== undefined) {
151+
masked.password = '••••••••'
152+
}
153+
if (masked.application_credential_secret !== undefined) {
154+
masked.application_credential_secret = '••••••••'
155+
}
156+
return { ...entry, auth: masked }
157+
}

0 commit comments

Comments
 (0)