Skip to content
Draft
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
6 changes: 5 additions & 1 deletion backend/src/handlers/getAppStatus.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { once } from "node:events";
import { AppNotFoundError } from "../service/common/errors.ts";
import { AppNotFoundError, ValidationError } from "../service/common/errors.ts";
import { getAppStatus, type StatusUpdate } from "../service/getAppStatus.ts";
import { json, type HandlerMap } from "../types.ts";
import type { AuthenticatedRequest } from "./index.ts";
Expand Down Expand Up @@ -41,6 +41,10 @@ export const getAppStatusHandler: HandlerMap["getAppStatus"] = async (
update,
);
} catch (e) {
if (e instanceof ValidationError) {
return json(400, res, { code: 400, message: e.message });
}

if (e instanceof AppNotFoundError) {
return json(404, res, { code: 404, message: "App not found." });
}
Expand Down
13 changes: 11 additions & 2 deletions backend/src/lib/cluster/resources.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { getOctokit } from "../octokit.ts";
import { createIngressConfig } from "./resources/ingress.ts";
import { createServiceConfig } from "./resources/service.ts";
import {
createDeploymentConfig,
createStatefulSetConfig,
generateAutomaticEnvVars,
} from "./resources/statefulset.ts";
Expand All @@ -37,6 +38,10 @@ export const MAX_STS_NAME_LEN = 60;

export const getRandomTag = (): string => randomBytes(4).toString("hex");
export const RANDOM_TAG_LEN = 8;

export const isStatefulSet = (config: WorkloadConfig) =>
config.mounts.length > 0;

export interface K8sObject {
apiVersion: string;
kind: string;
Expand Down Expand Up @@ -195,9 +200,13 @@ export const createAppConfigsFromDeployment = async (

const svc = createServiceConfig(params);
const ingress = createIngressConfig(params);
const statefulSet = await createStatefulSetConfig(params);

configs.push(statefulSet, svc);
const deploymentSpec =
params.mounts.length === 0
? await createDeploymentConfig(params)
: await createStatefulSetConfig(params);

configs.push(deploymentSpec, svc);
if (ingress !== null) {
// ^ Can be null if APP_DOMAIN is not set, meaning no Ingress should be created for the app
configs.push(ingress);
Expand Down
71 changes: 70 additions & 1 deletion backend/src/lib/cluster/resources/statefulset.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import type { V1EnvVar, V1StatefulSet } from "@kubernetes/client-node";
import type {
V1Deployment,
V1EnvVar,
V1StatefulSet,
} from "@kubernetes/client-node";
import crypto from "node:crypto";
import type { Octokit } from "octokit";
import type { App, Deployment, WorkloadConfig } from "../../../db/models.ts";
Expand Down Expand Up @@ -195,3 +199,68 @@ export const createStatefulSetConfig = async (

return base;
};

export const createDeploymentConfig = async (
params: DeploymentParams,
): Promise<V1Deployment & K8sObject> => {
const base = {
apiVersion: "apps/v1",
kind: "Deployment",
metadata: {
name: params.name,
namespace: params.namespace,
},
spec: {
selector: {
matchLabels: {
app: params.name,
},
},
replicas: params.replicas,
template: {
metadata: {
labels: {
app: params.name,
},
},
spec: {
automountServiceAccountToken: false,
// Set to an empty array (instead of undefined) so that disabling collectLogs in an existing app
// removes the initContainer
initContainers: [],
volumes: [], // same as above
containers: [
{
name: params.name,
image: params.image,
imagePullPolicy: "Always",
ports: [
{
containerPort: params.port,
protocol: "TCP",
},
],
resources: {
requests: params.requests,
limits: params.limits,
},
env: params.env,
lifecycle: {},
},
],
},
},
},
};

if (params.collectLogs) {
base.spec.template = await wrapWithLogExporter(
"runtime",
params.logIngestSecret,
params.deploymentId,
base.spec.template,
);
}

return base;
};
61 changes: 39 additions & 22 deletions backend/src/service/getAppByID.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,40 +15,55 @@ export async function getAppByID(appId: number, userId: number) {
throw new AppNotFoundError();
}

const [org, appGroup, currentConfig] = await Promise.all([
db.org.getById(app.orgId),
db.appGroup.getById(app.appGroupId),
db.deployment.getConfig(recentDeployment.id),
]);

// Fetch the current StatefulSet to read its labels
const getK8sDeployment = async () => {
if (currentConfig.appType !== "workload") {
return null;
}
try {
const { AppsV1Api: api } = await getClientsForRequest(
userId,
app.projectId,
["AppsV1Api"],
);
return await api.readNamespacedStatefulSet({
namespace: app.namespace,
name: app.name,
});
if (currentConfig.asWorkloadConfig().mounts.length > 0) {
return await api.readNamespacedStatefulSet({
namespace: app.namespace,
name: app.name,
});
} else {
return await api.readNamespacedDeployment({
namespace: app.namespace,
name: app.name,
});
}
} catch {}
};

const [org, appGroup, currentConfig, activeDeployment] = await Promise.all([
db.org.getById(app.orgId),
db.appGroup.getById(app.appGroupId),
db.deployment.getConfig(recentDeployment.id),
(await getK8sDeployment())?.spec?.template?.metadata?.labels?.[
"anvilops.rcac.purdue.edu/deployment-id"
],
// Fetch repository info if this app is deployed from a Git repository
const [{ repoId, repoURL }, activeDeployment] = await Promise.all([
(async () => {
if (currentConfig.source === "GIT" && org.githubInstallationId) {
const octokit = await getOctokit(org.githubInstallationId);
const repo = await getRepoById(octokit, currentConfig.repositoryId);
return { repoId: repo.id, repoURL: repo.html_url };
} else {
return { repoId: undefined, repoURL: undefined };
}
})(),
getK8sDeployment(),
]);

// Fetch repository info if this app is deployed from a Git repository
const { repoId, repoURL } = await (async () => {
if (currentConfig.source === "GIT" && org.githubInstallationId) {
const octokit = await getOctokit(org.githubInstallationId);
const repo = await getRepoById(octokit, currentConfig.repositoryId);
return { repoId: repo.id, repoURL: repo.html_url };
} else {
return { repoId: undefined, repoURL: undefined };
}
})();
const activeDeploymentId =
activeDeployment?.metadata?.labels?.[
"anvilops.rcac.purdue.edu/deployment-id"
];

return {
id: app.id,
Expand All @@ -68,7 +83,9 @@ export async function getAppByID(appId: number, userId: number) {
name: !appGroup.isMono ? appGroup.name : undefined,
id: app.appGroupId,
},
activeDeployment: activeDeployment ? parseInt(activeDeployment) : undefined,
activeDeployment: activeDeploymentId
? parseInt(activeDeploymentId)
: undefined,
deploymentCount,
};
}
107 changes: 68 additions & 39 deletions backend/src/service/getAppStatus.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,19 @@
import {
AbortError,
V1StatefulSet,
type CoreV1EventList,
type KubernetesListObject,
type KubernetesObject,
type V1Deployment,
type V1PodCondition,
type V1PodList,
type V1StatefulSet,
type Watch,
} from "@kubernetes/client-node";
import { metrics, ValueType } from "@opentelemetry/api";
import { ValueType, metrics } from "@opentelemetry/api";
import { db } from "../db/index.ts";
import { getClientsForRequest } from "../lib/cluster/kubernetes.ts";
import { AppNotFoundError } from "./common/errors.ts";
import { isStatefulSet } from "../lib/cluster/resources.ts";
import { AppNotFoundError, ValidationError } from "./common/errors.ts";

const meter = metrics.getMeter("app_status_viewer");
const concurrentViewers = meter.createUpDownCounter(
Expand All @@ -31,20 +33,27 @@ export async function getAppStatus(
abortController: AbortController,
callback: (status: StatusUpdate) => Promise<void>,
) {
const app = await db.app.getById(appId, {
requireUser: { id: userId },
});
const [app, config] = await Promise.all([
db.app.getById(appId, {
requireUser: { id: userId },
}),
db.app.getDeploymentConfig(appId),
]);

if (!app) {
throw new AppNotFoundError();
}

if (config.appType === "helm") {
throw new ValidationError("Cannot get app status for helm apps");
}

let pods: V1PodList;
let statefulSet: V1StatefulSet;
let deployment: V1StatefulSet | V1Deployment;
let events: CoreV1EventList;

const update = async () => {
if (!pods || !events || !statefulSet) return;
if (!pods || !events || !deployment) return;
const newStatus = {
pods: pods.items.map((pod) => ({
id: pod.metadata?.uid,
Expand All @@ -63,24 +72,25 @@ export async function getAppStatus(
image: pod.status?.containerStatuses?.[0]?.image,
containerReady: pod.status?.containerStatuses?.[0]?.ready,
containerState: pod.status?.containerStatuses?.[0]?.state,
lastState: pod.status?.containerStatuses?.[0].lastState,
ip: pod.status.podIP,
lastState: pod.status?.containerStatuses?.[0]?.lastState,
ip: pod.status?.podIP,
})),
events: events.items.map((event) => ({
reason: event.reason,
message: event.message,
count: event.count,
firstTimestamp: event.firstTimestamp.toISOString(),
lastTimestamp: event.lastTimestamp.toISOString(),
firstTimestamp: event.firstTimestamp?.toISOString(),
lastTimestamp: event.lastTimestamp?.toISOString(),
})),
statefulSet: {
readyReplicas: statefulSet.status.readyReplicas,
updatedReplicas: statefulSet.status.currentReplicas,
replicas: statefulSet.status.replicas,
generation: statefulSet.metadata.generation,
observedGeneration: statefulSet.status.observedGeneration,
currentRevision: statefulSet.status.currentRevision,
updateRevision: statefulSet.status.updateRevision,
deployment: {
readyReplicas: deployment.status?.readyReplicas,
replicas: deployment.spec?.replicas,
generation: deployment.metadata?.generation,
observedGeneration: deployment.status?.observedGeneration,
...(deployment instanceof V1StatefulSet && {
currentRevision: deployment.status?.currentRevision,
updateRevision: deployment.status?.updateRevision,
}),
},
};

Expand Down Expand Up @@ -128,25 +138,44 @@ export async function getAppStatus(
);
abortController.signal.addEventListener("abort", () => podWatcher.abort());

const statefulSetWatcher = await watchList(
watch,
`/apis/apps/v1/namespaces/${ns}/statefulsets`,
async () =>
await apps.listNamespacedStatefulSet({
namespace: ns,
}),
{},
async (newValue) => {
statefulSet = newValue.items.find(
(it) => it.metadata.name === app.name,
);
await update();
},
close,
);
abortController.signal.addEventListener("abort", () =>
statefulSetWatcher.abort(),
);
let watcher: Awaited<ReturnType<typeof watchList>>;
if (isStatefulSet(config.asWorkloadConfig())) {
watcher = await watchList(
watch,
`/apis/apps/v1/namespaces/${ns}/statefulsets`,
async () =>
await apps.listNamespacedStatefulSet({
namespace: ns,
}),
{},
async (newValue) => {
deployment = newValue.items.find(
(it) => it.metadata.name === app.name,
);
await update();
},
close,
);
} else {
watcher = await watchList(
watch,
`/apis/apps/v1/namespaces/${ns}/deployments`,
async () =>
await apps.listNamespacedDeployment({
namespace: ns,
}),
{},
async (newValue) => {
deployment = newValue.items.find(
(it) => it.metadata.name === app.name,
);
await update();
},
close,
);
}

abortController.signal.addEventListener("abort", () => watcher.abort());

const fieldSelector = `involvedObject.kind=StatefulSet,involvedObject.name=${app.name},type=Warning`;

Expand Down
2 changes: 1 addition & 1 deletion backend/src/service/helper/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,7 @@ export class AppService {
app.type === "update" ? app.existingAppId : undefined,
);
} else if (app.config.appType === "helm") {
if (!env.ALLOW_HELM_DEPLOYMENTS) {
if (env.ALLOW_HELM_DEPLOYMENTS !== "true") {
throw new ValidationError("Helm deployments are disabled");
}
}
Expand Down
2 changes: 1 addition & 1 deletion backend/src/service/helper/deploymentConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,7 @@ export class DeploymentConfigService {
replicas: config.replicas,
port: config.port,
mounts: config.mounts,
commitHash: "unknown",
commitHash: null,
imageTag: config.imageTag,
};
}
Expand Down
Loading