mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-10-28 15:51:26 +00:00
feat(services): clone/move ops + owner-scoped registry credentials (C4)
Service lifecycle: POST /services/:id/clone duplicates config, volumes, domains, access gates, builder settings, and variables (secret ciphertext copied verbatim); POST /services/:id/move re-parents to another owned project. Both enforce ownership, reject name conflicts, and audit. Registries: CRUD at /registries — host-matched pull credentials for prebuilt-image deploys, encrypted at rest via secrets pkg, never returned (has_password flag). Empty password on update keeps the stored credential; host normalized (scheme/case/trailing slash). Credentials thread through BuildConfig → BuildRequest → pull auth config, used only for the pull. Surfaces: CLI services clone|move + registries group, 7 MCP tools, OpenAPI paths/schemas, frontend Lifecycle card (clone/move/delete) and Settings registries section, llms.txt + CLI.md. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
912f6f5142
commit
3664eaed1a
20 files changed
+1591
-13
No files matched your search
@@ -20,6 +20,10 @@ import {
|
||||
createUserToken,
|
||||
listUserTokens,
|
||||
revokeUserToken,
|
||||
listRegistries,
|
||||
createRegistry,
|
||||
deleteRegistry,
|
||||
type Registry,
|
||||
listDatabases,
|
||||
databaseAction,
|
||||
updateDatabaseBackupSchedule,
|
||||
@@ -1430,6 +1434,135 @@ function UserTokensSection({ isAdmin }: { isAdmin: boolean }) {
|
||||
);
|
||||
}
|
||||
|
||||
function RegistriesSection() {
|
||||
const queryClient = useQueryClient();
|
||||
const registriesQuery = useQuery({
|
||||
queryKey: ['registries'],
|
||||
queryFn: listRegistries,
|
||||
});
|
||||
const [name, setName] = useState('');
|
||||
const [host, setHost] = useState('');
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
createRegistry({
|
||||
name: name.trim(),
|
||||
host: host.trim(),
|
||||
username: username.trim() || undefined,
|
||||
password: password || undefined,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
setName('');
|
||||
setHost('');
|
||||
setUsername('');
|
||||
setPassword('');
|
||||
queryClient.invalidateQueries({ queryKey: ['registries'] });
|
||||
},
|
||||
});
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => deleteRegistry(id),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['registries'] }),
|
||||
});
|
||||
|
||||
const registries = registriesQuery.data ?? [];
|
||||
|
||||
return (
|
||||
<section className="panel p-6">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-10 h-10 rounded-[var(--radius-md)] bg-[var(--accent-primary-soft)] flex items-center justify-center">
|
||||
<Archive size={18} className="text-[var(--accent-primary)]" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-[var(--text-primary)]">Registry credentials</h2>
|
||||
<p className="text-xs text-[var(--text-tertiary)]">Pull private images on deploy - matched by image host, stored encrypted</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Name (e.g. ghcr)"
|
||||
className="h-9 flex-1 min-w-[120px] rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 text-sm text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-[var(--accent-primary)] transition-all"
|
||||
/>
|
||||
<input
|
||||
value={host}
|
||||
onChange={(e) => setHost(e.target.value)}
|
||||
placeholder="Host (ghcr.io, docker.io)"
|
||||
className="h-9 flex-1 min-w-[160px] rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 text-sm text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-[var(--accent-primary)] transition-all"
|
||||
/>
|
||||
<input
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
placeholder="Username"
|
||||
className="h-9 w-40 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 text-sm text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-[var(--accent-primary)] transition-all"
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="Password / token"
|
||||
className="h-9 w-44 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 text-sm text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-[var(--accent-primary)] transition-all"
|
||||
/>
|
||||
<button
|
||||
onClick={() => createMutation.mutate()}
|
||||
disabled={createMutation.isPending || !name.trim() || !host.trim()}
|
||||
className="h-9 px-4 rounded-[var(--radius-md)] text-sm font-medium text-[var(--accent-on)] disabled:opacity-50 disabled:cursor-not-allowed transition-all"
|
||||
style={{ background: 'var(--accent-primary)' }}
|
||||
>
|
||||
{createMutation.isPending ? 'Adding…' : 'Add registry'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{createMutation.isError ? (
|
||||
<p className="text-xs text-[var(--error)]">
|
||||
{createMutation.error instanceof Error ? createMutation.error.message : 'Failed to add registry'}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{registriesQuery.isLoading ? (
|
||||
<div className="py-4 text-center">
|
||||
<Loader2 size={16} className="animate-spin mx-auto text-[var(--text-tertiary)]" />
|
||||
</div>
|
||||
) : registries.length === 0 ? (
|
||||
<p className="text-xs text-[var(--text-tertiary)]">
|
||||
No credentials. Image pulls are anonymous unless a registry matching the image host exists here.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-1.5 border-t border-[var(--border-subtle)] pt-3">
|
||||
{registries.map((r: Registry) => (
|
||||
<div key={r.id} className="flex items-center justify-between gap-2 text-xs">
|
||||
<div className="min-w-0">
|
||||
<span className="text-[var(--text-primary)]">{r.name}</span>
|
||||
<span className="ml-2 mono text-[var(--text-muted)]">{r.host}</span>
|
||||
{r.username ? <span className="ml-2 text-[var(--text-secondary)]">{r.username}</span> : null}
|
||||
<span className="ml-2 rounded px-1.5 py-0.5 bg-[var(--surface-muted)] text-[var(--text-secondary)]">
|
||||
{r.has_password ? 'password set' : 'anonymous'}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (window.confirm(`Remove credentials for ${r.host}?`)) {
|
||||
deleteMutation.mutate(r.id);
|
||||
}
|
||||
}}
|
||||
disabled={deleteMutation.isPending}
|
||||
className="text-[var(--error)] hover:underline disabled:opacity-50 shrink-0"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function SettingsPage() {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
@@ -1603,6 +1736,7 @@ export function SettingsPage() {
|
||||
|
||||
{/* Personal Access Tokens */}
|
||||
<UserTokensSection isAdmin={profileQuery.data?.isAdmin ?? false} />
|
||||
<RegistriesSection />
|
||||
|
||||
{/* Platform Settings - owner only */}
|
||||
{profileQuery.data?.isAdmin ? <PlatformSettingsSection /> : null}
|
||||
|
||||
@@ -43,6 +43,9 @@ import {
|
||||
removeServiceDomain,
|
||||
setDefaultServiceDomain,
|
||||
checkServiceDomains,
|
||||
cloneService,
|
||||
moveService,
|
||||
listProjects,
|
||||
} from '@/lib/api-client';
|
||||
import { getDemoProjectById, getDemoServiceById, getDemoCronJobsByService } from '@/lib/demo-data';
|
||||
import { useDemoMode } from '@/lib/demo-mode';
|
||||
@@ -412,6 +415,36 @@ export function ServiceDetailPage() {
|
||||
},
|
||||
});
|
||||
|
||||
const projectsQuery = useQuery({
|
||||
queryKey: ['projects'],
|
||||
queryFn: () => listProjects({ limit: 100 }),
|
||||
enabled: !isDemoMode && activeSection === 'settings',
|
||||
});
|
||||
|
||||
const cloneMutation = useMutation({
|
||||
mutationFn: (input: { name?: string; project_id?: string }) => cloneService(serviceId, input),
|
||||
onSuccess: (data) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['project-services'] });
|
||||
const targetProject = lifecycleForm?.cloneProject || projectId;
|
||||
navigate(`/projects/${targetProject}/services/${data.service_id}`);
|
||||
},
|
||||
});
|
||||
|
||||
const moveMutation = useMutation({
|
||||
mutationFn: (targetProjectId: string) => moveService(serviceId, targetProjectId),
|
||||
onSuccess: (_data, targetProjectId) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['project-services'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['projects'] });
|
||||
navigate(`/projects/${targetProjectId}/services/${serviceId}`);
|
||||
},
|
||||
});
|
||||
|
||||
const [lifecycleForm, setLifecycleForm] = useState<{
|
||||
cloneName: string;
|
||||
cloneProject: string;
|
||||
moveTarget: string;
|
||||
} | null>(null);
|
||||
|
||||
const invalidateService = () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['service', serviceId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['service-runtime', serviceId] });
|
||||
@@ -2632,6 +2665,135 @@ export function ServiceDetailPage() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isDemoMode && signedIn && (
|
||||
<div className="mt-6 pt-6 border-t border-[var(--border-subtle)]">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-sm font-medium text-[var(--text-primary)]">Lifecycle</h3>
|
||||
{lifecycleForm === null && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setLifecycleForm({ cloneName: `${service.name}-copy`, cloneProject: '', moveTarget: '' })}
|
||||
className="px-3 py-1.5 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-xs font-medium hover:border-[var(--border-default)] transition-colors"
|
||||
>
|
||||
Manage
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{lifecycleForm === null ? (
|
||||
<p className="text-xs text-[var(--text-tertiary)]">
|
||||
Duplicate this service into the same or another project, move it between projects, or delete it.
|
||||
</p>
|
||||
) : (
|
||||
<div className="rounded-[var(--radius-md)] border border-[var(--border-subtle)] p-4 space-y-4">
|
||||
<div>
|
||||
<label className="block text-xs text-[var(--text-tertiary)] mb-1">Clone</label>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<input
|
||||
value={lifecycleForm.cloneName}
|
||||
onChange={(e) => setLifecycleForm({ ...lifecycleForm, cloneName: e.target.value })}
|
||||
placeholder={`${service.name}-copy`}
|
||||
className="w-52 px-3 py-2 rounded-[var(--radius-md)] bg-[var(--surface-muted)] border border-[var(--border-subtle)] text-sm mono text-[var(--text-primary)]"
|
||||
/>
|
||||
<select
|
||||
value={lifecycleForm.cloneProject}
|
||||
onChange={(e) => setLifecycleForm({ ...lifecycleForm, cloneProject: e.target.value })}
|
||||
className="px-3 py-2 rounded-[var(--radius-md)] bg-[var(--surface-muted)] border border-[var(--border-subtle)] text-sm text-[var(--text-primary)]"
|
||||
>
|
||||
<option value="">same project</option>
|
||||
{(projectsQuery.data ?? [])
|
||||
.filter((p) => p.id !== projectId)
|
||||
.map((p) => (
|
||||
<option key={p.id} value={p.id}>{p.name}</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
disabled={cloneMutation.isPending}
|
||||
onClick={() =>
|
||||
cloneMutation.mutate({
|
||||
name: lifecycleForm.cloneName.trim() || undefined,
|
||||
project_id: lifecycleForm.cloneProject || undefined,
|
||||
})
|
||||
}
|
||||
className="px-4 py-2 rounded-[var(--radius-md)] bg-[var(--accent-primary)] text-[var(--accent-on)] text-sm font-medium disabled:opacity-50"
|
||||
>
|
||||
{cloneMutation.isPending ? 'Cloning…' : 'Clone'}
|
||||
</button>
|
||||
</div>
|
||||
<p className="mt-1 text-[10px] text-[var(--text-tertiary)]">
|
||||
Copies config, volumes, domains, and variables. The clone starts stopped.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs text-[var(--text-tertiary)] mb-1">Move to project</label>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<select
|
||||
value={lifecycleForm.moveTarget}
|
||||
onChange={(e) => setLifecycleForm({ ...lifecycleForm, moveTarget: e.target.value })}
|
||||
className="px-3 py-2 rounded-[var(--radius-md)] bg-[var(--surface-muted)] border border-[var(--border-subtle)] text-sm text-[var(--text-primary)]"
|
||||
>
|
||||
<option value="">choose a project…</option>
|
||||
{(projectsQuery.data ?? [])
|
||||
.filter((p) => p.id !== projectId)
|
||||
.map((p) => (
|
||||
<option key={p.id} value={p.id}>{p.name}</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
disabled={moveMutation.isPending || !lifecycleForm.moveTarget}
|
||||
onClick={() => {
|
||||
if (window.confirm(`Move ${service.name} to another project?`)) {
|
||||
moveMutation.mutate(lifecycleForm.moveTarget);
|
||||
}
|
||||
}}
|
||||
className="px-4 py-2 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-sm font-medium hover:border-[var(--border-default)] disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{moveMutation.isPending ? 'Moving…' : 'Move'}
|
||||
</button>
|
||||
</div>
|
||||
<p className="mt-1 text-[10px] text-[var(--text-tertiary)]">
|
||||
Variables, domains, and volumes move with the service. Running containers keep running.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="pt-3 border-t border-[var(--border-subtle)]">
|
||||
<label className="block text-xs text-[var(--error)] mb-1">Delete service</label>
|
||||
<button
|
||||
type="button"
|
||||
disabled={deleteServiceMutation.isPending}
|
||||
onClick={() => {
|
||||
if (window.confirm(`Delete ${service.name}? This removes its containers and cannot be undone.`)) {
|
||||
deleteServiceMutation.mutate();
|
||||
}
|
||||
}}
|
||||
className="px-4 py-2 rounded-[var(--radius-md)] border border-[var(--error-soft)] text-[var(--error)] text-sm font-medium hover:bg-[var(--error-soft)] disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{deleteServiceMutation.isPending ? 'Deleting…' : 'Delete'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setLifecycleForm(null)}
|
||||
className="px-4 py-2 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-sm text-[var(--text-primary)]"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
{(cloneMutation.isError || moveMutation.isError) && (
|
||||
<p className="text-xs text-[var(--error)]">
|
||||
{((cloneMutation.error ?? moveMutation.error) as Error)?.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -971,6 +971,276 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/services/{id}/clone": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/**
|
||||
* Clone service
|
||||
* @description Duplicates config, volumes, domains, access gates, builder settings, and variables into the same or another project. Containers are not cloned — the copy starts stopped.
|
||||
*/
|
||||
post: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: {
|
||||
content: {
|
||||
"application/json": {
|
||||
/** @description Name for the clone (default "<name>-copy") */
|
||||
name?: string;
|
||||
/**
|
||||
* Format: uuid
|
||||
* @description Target project (default same project; caller must own it)
|
||||
*/
|
||||
project_id?: string;
|
||||
/** @enum {string} */
|
||||
environment?: "production" | "preview" | "development";
|
||||
};
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Clone created */
|
||||
201: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": {
|
||||
/** Format: uuid */
|
||||
service_id?: string;
|
||||
name?: string;
|
||||
message?: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
/** @description Name already exists in target project */
|
||||
409: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/services/{id}/move": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/**
|
||||
* Move service
|
||||
* @description Re-parents the service to another project the caller owns. Variables, domains, and volumes follow the service; running containers keep running and join the new project's network on next deploy.
|
||||
*/
|
||||
post: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": {
|
||||
/** Format: uuid */
|
||||
project_id: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Service moved */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
/** @description Name conflict in target project */
|
||||
409: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/registries": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/**
|
||||
* List registry credentials
|
||||
* @description Private-image pull credentials for the caller. Passwords are never returned — `has_password` signals presence.
|
||||
*/
|
||||
get: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Registry list */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": {
|
||||
registries?: components["schemas"]["Registry"][];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
put?: never;
|
||||
/**
|
||||
* Add registry credential
|
||||
* @description Credentials are matched by image host (e.g. ghcr.io, registry.example.com, docker.io for Docker Hub) on prebuilt-image deploys. Password stored encrypted at rest.
|
||||
*/
|
||||
post: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["RegistryRequest"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Registry created */
|
||||
201: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
/** @description A registry for this host already exists */
|
||||
409: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/registries/{id}": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
/**
|
||||
* Update registry credential
|
||||
* @description Empty password keeps the stored credential — delete and recreate to remove a password.
|
||||
*/
|
||||
put: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["RegistryRequest"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Registry updated */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
/** @description Registry not found */
|
||||
404: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
post?: never;
|
||||
/** Remove registry credential */
|
||||
delete: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Registry removed */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
/** @description Registry not found */
|
||||
404: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/services/{id}/domains": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -8221,6 +8491,26 @@ export interface components {
|
||||
/** @description Plaintext on write — stored as bcrypt htpasswd; never returned */
|
||||
password: string;
|
||||
};
|
||||
Registry: {
|
||||
/** Format: uuid */
|
||||
id?: string;
|
||||
name?: string;
|
||||
/** @description Registry host matched against the image reference — ghcr.io, registry.example.com, docker.io for Docker Hub */
|
||||
host?: string;
|
||||
username?: string;
|
||||
has_password?: boolean;
|
||||
/** Format: date-time */
|
||||
created_at?: string;
|
||||
/** Format: date-time */
|
||||
updated_at?: string;
|
||||
};
|
||||
RegistryRequest: {
|
||||
name: string;
|
||||
host: string;
|
||||
username?: string;
|
||||
/** @description Plaintext on write — stored encrypted; never returned. Empty on update keeps the stored credential. */
|
||||
password?: string;
|
||||
};
|
||||
ServiceVolume: {
|
||||
/**
|
||||
* @description volume = docker named volume, bind = host path
|
||||
|
||||
@@ -1185,6 +1185,68 @@ export async function deleteService(serviceId: string): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
export type CloneServiceInput = {
|
||||
name?: string;
|
||||
project_id?: string;
|
||||
environment?: string;
|
||||
};
|
||||
|
||||
export async function cloneService(serviceId: string, input: CloneServiceInput): Promise<{ service_id: string; name: string }> {
|
||||
return requestJson<{ service_id: string; name: string }>(`/services/${serviceId}/clone`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
}
|
||||
|
||||
export async function moveService(serviceId: string, projectId: string): Promise<void> {
|
||||
await requestJson(`/services/${serviceId}/move`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ project_id: projectId }),
|
||||
});
|
||||
}
|
||||
|
||||
export type Registry = {
|
||||
id: string;
|
||||
name: string;
|
||||
host: string;
|
||||
username?: string;
|
||||
has_password: boolean;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
};
|
||||
|
||||
export type RegistryInput = {
|
||||
name: string;
|
||||
host: string;
|
||||
username?: string;
|
||||
password?: string;
|
||||
};
|
||||
|
||||
export async function listRegistries(): Promise<Registry[]> {
|
||||
const payload = await requestJson<{ registries?: Registry[] }>('/registries');
|
||||
return payload.registries ?? [];
|
||||
}
|
||||
|
||||
export async function createRegistry(input: RegistryInput): Promise<Registry> {
|
||||
const payload = await requestJson<{ registry: Registry }>('/registries', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
return payload.registry;
|
||||
}
|
||||
|
||||
export async function updateRegistry(id: string, input: RegistryInput): Promise<Registry> {
|
||||
const payload = await requestJson<{ registry: Registry }>(`/registries/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
return payload.registry;
|
||||
}
|
||||
|
||||
export async function deleteRegistry(id: string): Promise<void> {
|
||||
await requestJson(`/registries/${id}`, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
function normalizeRuntime(runtime?: ServiceRuntime): ServiceRuntime {
|
||||
const state = runtime ?? { desired: 0, status: 'stopped' as const, urls: [], ports: [], containers: [] };
|
||||
// The API emits null (not []) for empty collections — coerce at the boundary.
|
||||
|
||||
Reference in new issue
Block a user