feat: display platform agent status and version
This commit is contained in:
@@ -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 '에이전트 시작 시 업데이트 확인을 마지막으로 수행한 시각';
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { IsNotEmpty, IsString } from 'class-validator';
|
||||
|
||||
export class AgentUpdateCheckDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
deviceId!: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
version!: string;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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<string>('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<string>('PLATFORM_AGENT_RELEASE_VERSION', '0.2.0');
|
||||
const releaseBaseUrl = this.configService
|
||||
.get<string>(
|
||||
'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<AuthLoginResponse['user']>,
|
||||
tailscaleHostname: string,
|
||||
provisioningMode: 'personal' | 'class',
|
||||
) {
|
||||
): Promise<string> {
|
||||
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) {
|
||||
|
||||
Reference in New Issue
Block a user