Skip to content
Merged
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
1 change: 1 addition & 0 deletions src/@types/C2D/ServiceOnDemand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,7 @@ export interface ServiceJob {
status: ServiceStatusNumber
statusText: string
dateCreated: string // ISO timestamp
updatedAt?: number
expiresAt: number // Unix ms timestamp
duration: number // requested seconds
exposedPorts: number[]
Expand Down
1 change: 1 addition & 0 deletions src/@types/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -466,6 +466,7 @@ export interface GetServicesCommand extends Command {
// only services created at/after this moment: ISO date string, or a Unix timestamp
// (seconds or milliseconds) as a string
fromTimestamp?: string
updatedSince?: string
}

export interface ServiceRestartCommand extends Command {
Expand Down
37 changes: 27 additions & 10 deletions src/components/core/service/getServices.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,13 +53,23 @@ export class GetServicesHandler extends CommandHandler {
if (command.fromTimestamp !== undefined) {
if (typeof command.fromTimestamp !== 'string')
return buildInvalidRequestMessage(
'Parameter : "fromTimestamp" is not a valid string'
'Parameter "fromTimestamp" is not a valid string'
)
if (parseFromTimestamp(command.fromTimestamp) === null)
if (!Number.isFinite(parseFromTimestamp(command.fromTimestamp)))
return buildInvalidRequestMessage(
`Parameter "fromTimestamp" is not a valid date: "${command.fromTimestamp}" — use an ISO date or a Unix timestamp`
)
}
if (command.updatedSince !== undefined) {
if (typeof command.updatedSince !== 'string')
return buildInvalidRequestMessage(
'Parameter "updatedSince" is not a valid string'
)
if (!Number.isFinite(parseFromTimestamp(command.updatedSince)))
return buildInvalidRequestMessage(
`Parameter "updatedSince" is not a valid date: "${command.updatedSince}" — use an ISO date or a Unix timestamp`
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
return validation
}

Expand All @@ -86,10 +96,11 @@ export class GetServicesHandler extends CommandHandler {
const jobs: ServiceJob[] = []
for (const eng of engines.getAllEngines()) {
const { hash } = eng.getC2DConfig()
if (task.status !== undefined || task.includeAllStatuses) {
// status-explicit listing: read ALL of this cluster's jobs (getServiceJob has no
// cluster column filter, so scope by clusterHash here), then narrow to the one
// status when given — `status` takes precedence over `includeAllStatuses`
if (
task.updatedSince !== undefined ||
task.status !== undefined ||
task.includeAllStatuses
) {
const all = (await eng.db.getServiceJob()).filter(
(j: ServiceJob) => j.clusterHash === hash
)
Expand All @@ -105,10 +116,16 @@ export class GetServicesHandler extends CommandHandler {
}

const fromMs = parseFromTimestamp(task.fromTimestamp)
const filtered =
fromMs === undefined || fromMs === null
? jobs
: jobs.filter((j) => Date.parse(j.dateCreated) >= fromMs)
const updatedSinceMs = parseFromTimestamp(task.updatedSince)
const filtered = jobs.filter(
(j) =>
(fromMs === undefined || fromMs === null
? true
: Date.parse(j.dateCreated) >= fromMs) &&
(updatedSinceMs === undefined || updatedSinceMs === null
? true
: (j.updatedAt ?? 0) >= updatedSinceMs)
)

return {
stream: Readable.from(JSON.stringify(filtered.map(toListedServiceJob))),
Expand Down
4 changes: 2 additions & 2 deletions src/components/database/sqliteCompute.ts
Original file line number Diff line number Diff line change
Expand Up @@ -280,7 +280,7 @@ export class SQLiteCompute implements ComputeDatabaseProvider {
job.status,
job.expiresAt,
job.dateCreated,
Buffer.from(JSON.stringify(job))
Buffer.from(JSON.stringify({ ...job, updatedAt: Date.now() }))
])
} catch (err) {
DATABASE_LOGGER.error('Could not insert service job on DB: ' + err.message)
Expand All @@ -301,7 +301,7 @@ export class SQLiteCompute implements ComputeDatabaseProvider {
job.clusterHash,
job.status,
job.expiresAt,
Buffer.from(JSON.stringify(job)),
Buffer.from(JSON.stringify({ ...job, updatedAt: Date.now() })),
job.serviceId
])
return changes
Expand Down
65 changes: 65 additions & 0 deletions src/test/unit/service/serviceHandlers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -613,6 +613,71 @@ describe('Service handlers', () => {
const out = await body(res)
expect(out).to.deep.equal([])
})

it('400 on an updatedSince that is unparseable, empty, or overflows', async () => {
const { node } = buildFakes()
// an explicit empty string and an overflowing digit-only value (→ Infinity) must
// be rejected, not fall through to an unfiltered all-statuses dump
for (const updatedSince of ['not-a-date', '', '9'.repeat(400)]) {
const res = await new GetServicesHandler(node).handle({
...baseTask,
updatedSince
} as any)
expect(
res.status.httpStatus,
`updatedSince=${JSON.stringify(updatedSince)}`
).to.equal(400)
}
})

it('updatedSince: returns every status changed at/after the cursor, from the all-jobs read', async () => {
// realistic epoch-ms so parseFromTimestamp treats the values as ms, not seconds
const base = 1_700_000_000_000
const older = makeJob({
serviceId: 'svc-old',
status: ServiceStatusNumber.Running,
updatedAt: base
})
const failed = makeJob({
serviceId: 'svc-fail',
status: ServiceStatusNumber.PullImageFailed,
updatedAt: base + 5000
})
const expired = makeJob({
serviceId: 'svc-exp',
status: ServiceStatusNumber.Expired,
updatedAt: base + 6000
})
const { node, engine } = buildFakes()
engine.db.getServiceJob.resolves([older, failed, expired])

const res = await new GetServicesHandler(node).handle({
...baseTask,
updatedSince: String(base + 3000)
} as any)
expect(res.status.httpStatus).to.equal(200)
// incremental read uses the all-jobs path, not the resource-holding query
expect(engine.db.getServiceJob.called).to.equal(true)
expect(engine.db.getRunningServiceJobs.called).to.equal(false)

const out = await body(res)
const ids = out.map((s: any) => s.serviceId)
// older is before the cursor → excluded; both changed-since are returned,
// proving all statuses (incl. the resource-set-hidden PullImageFailed/Expired) pass
expect(ids).to.have.members(['svc-fail', 'svc-exp'])
expect(ids).to.not.include('svc-old')
})

it('updatedSince: a record with no updatedAt is treated as 0 and excluded once the cursor advances', async () => {
const legacy = makeJob({ serviceId: 'svc-legacy', updatedAt: undefined })
const { node, engine } = buildFakes()
engine.db.getServiceJob.resolves([legacy])
const res = await new GetServicesHandler(node).handle({
...baseTask,
updatedSince: String(1_700_000_000_000)
} as any)
expect(await body(res)).to.deep.equal([])
})
})

describe('ServiceStartHandler', () => {
Expand Down
16 changes: 16 additions & 0 deletions src/test/unit/service/serviceJobsDatabase.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,22 @@ describe('Service Jobs Database', () => {
expect(found.expiresAt).to.equal(123456)
})

it('stamps updatedAt on insert and bumps it on every status change', async () => {
const before = Date.now()
const job = makeServiceJob()
await db.newServiceJob(job)
const [inserted] = await db.getServiceJob(job.serviceId)
expect(inserted.updatedAt).to.be.a('number')
expect(inserted.updatedAt).to.be.at.least(before)

// a later transition must advance updatedAt — this is the incremental-sync cursor
await new Promise((resolve) => setTimeout(resolve, 5))
job.status = ServiceStatusNumber.Stopped
await db.updateServiceJob(job)
const [updated] = await db.getServiceJob(job.serviceId)
expect(updated.updatedAt).to.be.greaterThan(inserted.updatedAt)
})

it('getRunningServiceJobs returns only active statuses for the cluster', async () => {
const running = makeServiceJob({ status: ServiceStatusNumber.Running })
const starting = makeServiceJob({ status: ServiceStatusNumber.Starting })
Expand Down
Loading