diff --git a/prisma/migrations/20260806143000_add_platform_agent_version/migration.sql b/prisma/migrations/20260806143000_add_platform_agent_version/migration.sql new file mode 100644 index 0000000..6639067 --- /dev/null +++ b/prisma/migrations/20260806143000_add_platform_agent_version/migration.sql @@ -0,0 +1,11 @@ +ALTER TABLE "provisioned_devices" +ADD COLUMN "agent_version" TEXT, +ADD COLUMN "agent_updated_at" TIMESTAMPTZ(3), +ADD COLUMN "agent_last_seen_at" TIMESTAMPTZ(3); + +CREATE INDEX "provisioned_devices_agent_last_seen_at_idx" +ON "provisioned_devices"("agent_last_seen_at"); + +COMMENT ON COLUMN "provisioned_devices"."agent_version" IS '에이전트가 마지막으로 보고한 실행 버전'; +COMMENT ON COLUMN "provisioned_devices"."agent_updated_at" IS '현재 에이전트 버전이 처음 보고된 시각'; +COMMENT ON COLUMN "provisioned_devices"."agent_last_seen_at" IS '에이전트 시작 시 업데이트 확인을 마지막으로 수행한 시각'; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 0868c5d..f077451 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -216,6 +216,9 @@ model ProvisionedDevice { kernel String? distro String? device Json? + agentVersion String? @map("agent_version") + agentUpdatedAt DateTime? @map("agent_updated_at") @db.Timestamptz(3) + agentLastSeenAt DateTime? @map("agent_last_seen_at") @db.Timestamptz(3) installedAt DateTime @default(now()) @map("installed_at") @db.Timestamptz(3) lastProvisionedAt DateTime @default(now()) @map("last_provisioned_at") @db.Timestamptz(3) createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) @@ -224,6 +227,7 @@ model ProvisionedDevice { @@unique([accountEmail, tailscaleHostname]) @@index([accountEmail]) @@index([tailscaleHostname]) + @@index([agentLastSeenAt]) @@map("provisioned_devices") } diff --git a/src/provisioning/dto/agent-update-check.dto.ts b/src/provisioning/dto/agent-update-check.dto.ts new file mode 100644 index 0000000..9ac11e5 --- /dev/null +++ b/src/provisioning/dto/agent-update-check.dto.ts @@ -0,0 +1,11 @@ +import { IsNotEmpty, IsString } from 'class-validator'; + +export class AgentUpdateCheckDto { + @IsString() + @IsNotEmpty() + deviceId!: string; + + @IsString() + @IsNotEmpty() + version!: string; +} diff --git a/src/provisioning/provisioning.controller.ts b/src/provisioning/provisioning.controller.ts index 920e29c..61078c4 100644 --- a/src/provisioning/provisioning.controller.ts +++ b/src/provisioning/provisioning.controller.ts @@ -7,6 +7,7 @@ import { ConsumeDeviceAuthorizationDto, CreateDeviceAuthorizationDto, } from './dto/device-authorization.dto'; +import { AgentUpdateCheckDto } from './dto/agent-update-check.dto'; import { ProvisioningService } from './provisioning.service'; import type { ProvisioningResponse } from './provisioning.service'; @@ -69,6 +70,14 @@ export class ProvisioningController { return this.provisioningService.consumeDeviceAuthorization(body); } + @Post('agent/update-check') + checkAgentUpdate( + @Headers('x-agent-gateway-token') token: string | undefined, + @Body() body: AgentUpdateCheckDto, + ) { + return this.provisioningService.checkAgentUpdate(token, body); + } + @Get('device-authorizations/registration/:reference') getDeviceAuthorizationByRegistrationReference( @Headers('authorization') authorization: string | undefined, diff --git a/src/provisioning/provisioning.service.ts b/src/provisioning/provisioning.service.ts index ef94bd2..e7c9563 100644 --- a/src/provisioning/provisioning.service.ts +++ b/src/provisioning/provisioning.service.ts @@ -18,6 +18,7 @@ import { ConsumeDeviceAuthorizationDto, CreateDeviceAuthorizationDto, } from './dto/device-authorization.dto'; +import { AgentUpdateCheckDto } from './dto/agent-update-check.dto'; export type ProvisioningResponse = { installTailscale: boolean; @@ -27,6 +28,7 @@ export type ProvisioningResponse = { tailscaleExtraArgs: string; provisioningMode: 'personal' | 'class'; agentGatewayToken: string; + agentDeviceId: string; }; type AuthLoginResponse = { @@ -276,7 +278,7 @@ export class ProvisioningService { provisioningDto.hostname || 'agent', headscaleUser, ); - await this.recordProvisionedDevice( + const agentDeviceId = await this.recordProvisionedDevice( provisioningDto, authUser, tailscaleHostname, @@ -304,6 +306,46 @@ export class ProvisioningService { 'AGENT_GATEWAY_TOKEN', '', ), + agentDeviceId, + }; + } + + async checkAgentUpdate(token: string | undefined, input: AgentUpdateCheckDto) { + const expectedToken = this.configService.get('AGENT_GATEWAY_TOKEN', ''); + if (!expectedToken || token !== expectedToken) { + throw new UnauthorizedException('invalid_agent_gateway_token'); + } + + const device = await this.prisma.provisionedDevice.findUnique({ + where: { id: input.deviceId }, + select: { id: true, agentVersion: true }, + }); + if (!device) { + throw new NotFoundException('Provisioned device was not found'); + } + + const now = new Date(); + await this.prisma.provisionedDevice.update({ + where: { id: device.id }, + data: { + agentVersion: input.version, + agentLastSeenAt: now, + ...(device.agentVersion === input.version ? {} : { agentUpdatedAt: now }), + }, + }); + + const latestVersion = this.configService.get('PLATFORM_AGENT_RELEASE_VERSION', '0.2.0'); + const releaseBaseUrl = this.configService + .get( + 'PLATFORM_AGENT_RELEASE_BASE_URL', + 'https://pub-ca72c40797144462bdb7d200c215d361.r2.dev/studio-files/public/platform-agent', + ) + .replace(/\/+$/, ''); + + return { + latestVersion, + releaseBaseUrl, + updateAvailable: this.isVersionNewer(latestVersion, input.version), }; } @@ -390,7 +432,7 @@ export class ProvisioningService { authUser: NonNullable, tailscaleHostname: string, provisioningMode: 'personal' | 'class', - ) { + ): Promise { const hostname = this.cleanString( provisioningDto.hostname || provisioningDto.device?.hostname || 'agent', ) ?? 'agent'; @@ -401,7 +443,7 @@ export class ProvisioningService { provisioningDto.workspaceRoot || provisioningDto.device?.workspaceRoot, ); const now = new Date(); - await this.prisma.provisionedDevice.upsert({ + const device = await this.prisma.provisionedDevice.upsert({ where: { accountEmail_tailscaleHostname: { accountEmail: authUser.email, @@ -450,6 +492,22 @@ export class ProvisioningService { lastProvisionedAt: now, }, }); + return device.id; + } + + private isVersionNewer(candidate: string, current: string): boolean { + const parse = (value: string) => value.replace(/^v/i, '').split('.').map((part) => { + const parsed = Number.parseInt(part, 10); + return Number.isFinite(parsed) ? parsed : 0; + }); + const candidateParts = parse(candidate); + const currentParts = parse(current); + const length = Math.max(candidateParts.length, currentParts.length); + for (let index = 0; index < length; index += 1) { + const diff = (candidateParts[index] ?? 0) - (currentParts[index] ?? 0); + if (diff !== 0) return diff > 0; + } + return false; } private cleanString(value?: string | null) {