mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-31 04:38:11 +00:00
fix: recognize clean one-shot completions in health gate and drift (#1691)
* fix: recognize clean one-shot completions in health gate and drift Treat exit 0 with restart policy no/absent as successful completion so init and migration jobs no longer fail post-update observation or show as service-missing, while long-running restart policies still fail closed. * fix: ignore residual health on clean one-shots and honor deploy.restart_policy Completed exit-0 jobs with no-restart intent no longer fail the health gate on leftover starting/unhealthy state, and Drift treats deploy.restart_policy with Compose precedence so any/on-failure services are not mistaken for one-shots. * fix: require explicit Compose restart no for one-shot recognition Docker inspect reports restart no for both intentional jobs and bare services that omit restart, so Health Gate and Drift now require declared restart:""no"" (or deploy.restart_policy condition none) and load Compose intent once per gate.
This commit is contained in:
@@ -36,7 +36,7 @@ function declared(services: DeclaredService[], parseError?: string): DeclaredCom
|
||||
function container(p: Partial<DependencyContainer> & { id: string }): DependencyContainer {
|
||||
return {
|
||||
name: p.id, service: null, composeProject: null, stack: 'app',
|
||||
state: 'running', image: 'img:latest', networks: [], volumes: [], ports: [], ...p,
|
||||
state: 'running', exitCode: null, image: 'img:latest', networks: [], volumes: [], ports: [], ...p,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -108,6 +108,147 @@ describe('assembleStackDrift - status', () => {
|
||||
expect(report.findings).toEqual([]);
|
||||
});
|
||||
|
||||
it('does not emit service-missing for a clean one-shot beside a running service', () => {
|
||||
const report = assembleStackDrift({
|
||||
stack: 'app',
|
||||
declared: declared([
|
||||
service({ name: 'app', image: 'app:1', restart: 'unless-stopped' }),
|
||||
service({ name: 'migrate', image: 'migrate:1', restart: 'no' }),
|
||||
]),
|
||||
containers: [
|
||||
container({ id: 'c1', service: 'app', image: 'app:1', state: 'running' }),
|
||||
container({ id: 'c2', service: 'migrate', image: 'migrate:1', state: 'exited', exitCode: 0 }),
|
||||
],
|
||||
});
|
||||
expect(findingKinds(report)).not.toContain('service-missing');
|
||||
expect(report.status).toBe('in-sync');
|
||||
expect(report.hasContainers).toBe(true);
|
||||
});
|
||||
|
||||
it('still emits service-missing when a one-shot exits non-zero', () => {
|
||||
const report = assembleStackDrift({
|
||||
stack: 'app',
|
||||
declared: declared([
|
||||
service({ name: 'app', image: 'app:1', restart: 'unless-stopped' }),
|
||||
service({ name: 'migrate', image: 'migrate:1', restart: 'no' }),
|
||||
]),
|
||||
containers: [
|
||||
container({ id: 'c1', service: 'app', image: 'app:1', state: 'running' }),
|
||||
container({ id: 'c2', service: 'migrate', image: 'migrate:1', state: 'exited', exitCode: 1 }),
|
||||
],
|
||||
});
|
||||
expect(findingKinds(report)).toContain('service-missing');
|
||||
expect(report.findings.find(f => f.kind === 'service-missing')?.service).toBe('migrate');
|
||||
});
|
||||
|
||||
it('still treats exited unless-stopped as missing even with exit 0', () => {
|
||||
const report = assembleStackDrift({
|
||||
stack: 'app',
|
||||
declared: declared([service({ name: 'web', restart: 'unless-stopped' })]),
|
||||
containers: [container({ id: 'c1', service: 'web', state: 'exited', exitCode: 0 })],
|
||||
});
|
||||
expect(report.status).toBe('missing-runtime');
|
||||
expect(report.hasContainers).toBe(false);
|
||||
});
|
||||
|
||||
it('fails closed when exitCode is null even with restart no', () => {
|
||||
const report = assembleStackDrift({
|
||||
stack: 'app',
|
||||
declared: declared([
|
||||
service({ name: 'app', image: 'app:1', restart: 'unless-stopped' }),
|
||||
service({ name: 'migrate', image: 'migrate:1', restart: 'no' }),
|
||||
]),
|
||||
containers: [
|
||||
container({ id: 'c1', service: 'app', image: 'app:1', state: 'running' }),
|
||||
container({ id: 'c2', service: 'migrate', image: 'migrate:1', state: 'exited', exitCode: null }),
|
||||
],
|
||||
});
|
||||
expect(findingKinds(report)).toContain('service-missing');
|
||||
expect(report.findings.find(f => f.kind === 'service-missing')?.service).toBe('migrate');
|
||||
});
|
||||
|
||||
it('does not treat absent declared restart as a clean one-shot', () => {
|
||||
const report = assembleStackDrift({
|
||||
stack: 'app',
|
||||
declared: declared([
|
||||
service({ name: 'app', image: 'app:1', restart: 'unless-stopped' }),
|
||||
service({ name: 'daemon-default', image: 'daemon:1' }),
|
||||
]),
|
||||
containers: [
|
||||
container({ id: 'c1', service: 'app', image: 'app:1' }),
|
||||
container({ id: 'c2', service: 'daemon-default', image: 'daemon:1', state: 'exited', exitCode: 0 }),
|
||||
],
|
||||
});
|
||||
expect(findingKinds(report)).toContain('service-missing');
|
||||
expect(report.findings.find((f) => f.kind === 'service-missing')?.service).toBe('daemon-default');
|
||||
});
|
||||
|
||||
it('all-one-shot stack with dedicated network is not missing-runtime but keeps network-missing and hasContainers false', () => {
|
||||
const report = assembleStackDrift({
|
||||
stack: 'app',
|
||||
declared: {
|
||||
services: [service({ name: 'migrate', restart: 'no', networks: ['jobs'] })],
|
||||
networks: { jobs: { external: false } },
|
||||
volumes: {},
|
||||
projectName: 'app',
|
||||
},
|
||||
containers: [
|
||||
container({
|
||||
id: 'c1', service: 'migrate', state: 'exited', exitCode: 0,
|
||||
networks: [{ name: 'app_jobs', id: 'j', ip: '' }],
|
||||
}),
|
||||
],
|
||||
networks: [depNet('app_jobs')],
|
||||
});
|
||||
expect(report.status).not.toBe('missing-runtime');
|
||||
expect(findingKinds(report)).not.toContain('service-missing');
|
||||
expect(report.hasContainers).toBe(false);
|
||||
expect(findingKinds(report)).toContain('network-missing');
|
||||
expect(report.status).toBe('drifted');
|
||||
});
|
||||
|
||||
it('all-one-shot stack without network findings is in-sync with hasContainers false', () => {
|
||||
const report = assembleStackDrift({
|
||||
stack: 'app',
|
||||
declared: declared([service({ name: 'migrate', restart: 'no' })]),
|
||||
containers: [container({ id: 'c1', service: 'migrate', state: 'exited', exitCode: 0 })],
|
||||
});
|
||||
expect(report.status).toBe('in-sync');
|
||||
expect(report.hasContainers).toBe(false);
|
||||
expect(report.findings).toEqual([]);
|
||||
});
|
||||
|
||||
it('emits service-missing when normalized restart is always (deploy any)', () => {
|
||||
const report = assembleStackDrift({
|
||||
stack: 'app',
|
||||
declared: declared([
|
||||
service({ name: 'app', image: 'app:1', restart: 'unless-stopped' }),
|
||||
service({ name: 'worker', image: 'worker:1', restart: 'always' }),
|
||||
]),
|
||||
containers: [
|
||||
container({ id: 'c1', service: 'app', image: 'app:1' }),
|
||||
container({ id: 'c2', service: 'worker', image: 'worker:1', state: 'exited', exitCode: 0 }),
|
||||
],
|
||||
});
|
||||
expect(findingKinds(report)).toContain('service-missing');
|
||||
expect(report.findings.find((f) => f.kind === 'service-missing')?.service).toBe('worker');
|
||||
});
|
||||
|
||||
it('emits service-missing when normalized restart is on-failure', () => {
|
||||
const report = assembleStackDrift({
|
||||
stack: 'app',
|
||||
declared: declared([
|
||||
service({ name: 'app', image: 'app:1', restart: 'unless-stopped' }),
|
||||
service({ name: 'worker', image: 'worker:1', restart: 'on-failure' }),
|
||||
]),
|
||||
containers: [
|
||||
container({ id: 'c1', service: 'app', image: 'app:1' }),
|
||||
container({ id: 'c2', service: 'worker', image: 'worker:1', state: 'exited', exitCode: 0 }),
|
||||
],
|
||||
});
|
||||
expect(findingKinds(report)).toContain('service-missing');
|
||||
});
|
||||
|
||||
it('counts a restarting container as deployed', () => {
|
||||
const report = assembleStackDrift({
|
||||
stack: 'app',
|
||||
@@ -456,6 +597,82 @@ describe('declaredFromEffectiveModel', () => {
|
||||
expect(converted.services[0].ports).toEqual([{ hostIp: '127.0.0.1', publishedPort: 8080, protocol: 'tcp' }]);
|
||||
});
|
||||
|
||||
it('preserves restart policy including no, unless-stopped, and absent', () => {
|
||||
const withNo = declaredFromEffectiveModel({
|
||||
projectName: 'app',
|
||||
services: [effSvc({ name: 'migrate', restart: 'no' })],
|
||||
networks: {},
|
||||
volumes: {},
|
||||
});
|
||||
expect(withNo.services[0].restart).toBe('no');
|
||||
|
||||
const withUnless = declaredFromEffectiveModel({
|
||||
projectName: 'app',
|
||||
services: [effSvc({ name: 'web', restart: 'unless-stopped' })],
|
||||
networks: {},
|
||||
volumes: {},
|
||||
});
|
||||
expect(withUnless.services[0].restart).toBe('unless-stopped');
|
||||
|
||||
const absent = declaredFromEffectiveModel({
|
||||
projectName: 'app',
|
||||
services: [effSvc({ name: 'job', restart: undefined })],
|
||||
networks: {},
|
||||
volumes: {},
|
||||
});
|
||||
expect(absent.services[0].restart).toBeNull();
|
||||
});
|
||||
|
||||
it('normalizes deploy.restart_policy conditions with Compose precedence', () => {
|
||||
const none = declaredFromEffectiveModel({
|
||||
projectName: 'app',
|
||||
services: [effSvc({
|
||||
name: 'migrate',
|
||||
restart: 'unless-stopped',
|
||||
deploy: { restart_policy: { condition: 'none' } },
|
||||
})],
|
||||
networks: {},
|
||||
volumes: {},
|
||||
});
|
||||
expect(none.services[0].restart).toBe('no');
|
||||
|
||||
const any = declaredFromEffectiveModel({
|
||||
projectName: 'app',
|
||||
services: [effSvc({
|
||||
name: 'worker',
|
||||
restart: 'no',
|
||||
deploy: { restart_policy: { condition: 'any' } },
|
||||
})],
|
||||
networks: {},
|
||||
volumes: {},
|
||||
});
|
||||
expect(any.services[0].restart).toBe('always');
|
||||
|
||||
const onFailure = declaredFromEffectiveModel({
|
||||
projectName: 'app',
|
||||
services: [effSvc({
|
||||
name: 'worker',
|
||||
restart: undefined,
|
||||
deploy: { restart_policy: { condition: 'on-failure' } },
|
||||
})],
|
||||
networks: {},
|
||||
volumes: {},
|
||||
});
|
||||
expect(onFailure.services[0].restart).toBe('on-failure');
|
||||
|
||||
const defaultAny = declaredFromEffectiveModel({
|
||||
projectName: 'app',
|
||||
services: [effSvc({
|
||||
name: 'worker',
|
||||
restart: 'no',
|
||||
deploy: { restart_policy: {} },
|
||||
})],
|
||||
networks: {},
|
||||
volumes: {},
|
||||
});
|
||||
expect(defaultAny.services[0].restart).toBe('always');
|
||||
});
|
||||
|
||||
it('normalizes networks so drift matches the rendered model', () => {
|
||||
const model: EffectiveModel = {
|
||||
projectName: 'myapp',
|
||||
|
||||
Reference in New Issue
Block a user