|
| 1 | +import {hostname} from 'node:os' |
1 | 2 | import {Command, Flags} from '@oclif/core' |
2 | | -import {createApiClient} from '../lib/api-client.js' |
| 3 | +import {createApiClient, apiPost, apiDelete} from '../lib/api-client.js' |
3 | 4 | import {resolveToken, resolveApiUrl} from '../lib/auth.js' |
4 | 5 | import {loadConfig, validate, fetchAllRefs, diff, formatPlan, apply, writeState, buildState} from '../lib/yaml/index.js' |
5 | 6 | import {checkEntitlements, formatEntitlementWarnings} from '../lib/yaml/entitlements.js' |
@@ -35,6 +36,14 @@ export default class Deploy extends Command { |
35 | 36 | description: 'Show what would change without applying (same as "devhelm plan")', |
36 | 37 | default: false, |
37 | 38 | }), |
| 39 | + 'force-unlock': Flags.boolean({ |
| 40 | + description: 'Force-break an existing deploy lock before acquiring', |
| 41 | + default: false, |
| 42 | + }), |
| 43 | + 'no-lock': Flags.boolean({ |
| 44 | + description: 'Skip deploy locking (not recommended for team use)', |
| 45 | + default: false, |
| 46 | + }), |
38 | 47 | 'api-url': Flags.string({description: 'Override API base URL'}), |
39 | 48 | 'api-token': Flags.string({description: 'Override API token'}), |
40 | 49 | verbose: Flags.boolean({char: 'v', description: 'Show verbose output', default: false}), |
@@ -111,27 +120,76 @@ export default class Deploy extends Command { |
111 | 120 | } |
112 | 121 | } |
113 | 122 |
|
114 | | - this.log('Applying changes...') |
115 | | - const applyResult = await apply(changeset, refs, client) |
116 | | - |
117 | | - for (const s of applyResult.succeeded) { |
118 | | - const icon = s.action === 'delete' ? '-' : s.action === 'update' ? '~' : '+' |
119 | | - this.log(` ${icon} ${s.resourceType} "${s.refKey}" — ${s.action}d`) |
| 123 | + let lockId: string | undefined |
| 124 | + if (!flags['no-lock']) { |
| 125 | + lockId = await this.acquireLock(client, flags['force-unlock']) |
120 | 126 | } |
121 | 127 |
|
122 | | - if (applyResult.failed.length > 0) { |
123 | | - this.log('') |
124 | | - for (const f of applyResult.failed) { |
125 | | - this.log(` ✗ ${f.resourceType} "${f.refKey}" — ${f.action} failed: ${f.error}`) |
| 128 | + try { |
| 129 | + this.log('Applying changes...') |
| 130 | + const applyResult = await apply(changeset, refs, client) |
| 131 | + |
| 132 | + for (const s of applyResult.succeeded) { |
| 133 | + const icon = s.action === 'delete' ? '-' : s.action === 'update' ? '~' : '+' |
| 134 | + this.log(` ${icon} ${s.resourceType} "${s.refKey}" — ${s.action}d`) |
126 | 135 | } |
127 | | - } |
128 | 136 |
|
129 | | - writeState(buildState(applyResult.stateEntries)) |
| 137 | + if (applyResult.failed.length > 0) { |
| 138 | + this.log('') |
| 139 | + for (const f of applyResult.failed) { |
| 140 | + this.log(` ✗ ${f.resourceType} "${f.refKey}" — ${f.action} failed: ${f.error}`) |
| 141 | + } |
| 142 | + } |
130 | 143 |
|
131 | | - this.log(`\nDone: ${applyResult.succeeded.length} succeeded, ${applyResult.failed.length} failed.`) |
| 144 | + writeState(buildState(applyResult.stateEntries)) |
132 | 145 |
|
133 | | - if (applyResult.failed.length > 0) { |
134 | | - this.exit(2) |
| 146 | + this.log(`\nDone: ${applyResult.succeeded.length} succeeded, ${applyResult.failed.length} failed.`) |
| 147 | + |
| 148 | + if (applyResult.failed.length > 0) { |
| 149 | + this.exit(2) |
| 150 | + } |
| 151 | + } finally { |
| 152 | + if (lockId) { |
| 153 | + await this.releaseLock(client, lockId) |
| 154 | + } |
135 | 155 | } |
136 | 156 | } |
| 157 | + |
| 158 | + private async acquireLock(client: ReturnType<typeof createApiClient>, forceUnlock: boolean): Promise<string | undefined> { |
| 159 | + if (forceUnlock) { |
| 160 | + try { |
| 161 | + await apiDelete(client, '/api/v1/deploy/lock/force') |
| 162 | + } catch { |
| 163 | + // Force-unlock is best-effort; the lock may not exist |
| 164 | + } |
| 165 | + } |
| 166 | + |
| 167 | + try { |
| 168 | + const resp = await apiPost<{data?: {id?: string}}>( |
| 169 | + client, '/api/v1/deploy/lock', |
| 170 | + {lockedBy: `${process.env.USER ?? 'cli'}@${hostname()}`, ttlMinutes: 10}, |
| 171 | + ) |
| 172 | + const lockId = resp.data?.id |
| 173 | + if (!lockId) { |
| 174 | + this.warn('Deploy lock acquired but no lock ID returned. Proceeding without lock protection.') |
| 175 | + } |
| 176 | + return lockId |
| 177 | + } catch (err) { |
| 178 | + const msg = err instanceof Error ? err.message : String(err) |
| 179 | + if (msg.includes('409') || msg.includes('Conflict') || msg.includes('lock held')) { |
| 180 | + this.warn(`Deploy lock conflict: ${msg}`) |
| 181 | + this.warn('Use --force-unlock to break the existing lock, or --no-lock to skip locking.') |
| 182 | + this.exit(3) |
| 183 | + } |
| 184 | + this.warn(`Failed to acquire deploy lock: ${msg}`) |
| 185 | + this.warn('Use --no-lock to skip locking if the lock service is unavailable.') |
| 186 | + this.exit(3) |
| 187 | + } |
| 188 | + } |
| 189 | + |
| 190 | + private async releaseLock(client: ReturnType<typeof createApiClient>, lockId: string): Promise<void> { |
| 191 | + try { |
| 192 | + await apiDelete(client, `/api/v1/deploy/lock/${lockId}`) |
| 193 | + } catch { /* best-effort release */ } |
| 194 | + } |
137 | 195 | } |
0 commit comments