Skip to content
Open
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
4 changes: 2 additions & 2 deletions cli/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion cli/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@yc-software/qm",
"version": "0.1.5",
"version": "0.1.6",
"license": "MIT",
"description": "Control-plane CLI for portable QM deployments on Docker, Fly, and AWS.",
"type": "module",
Expand Down
96 changes: 75 additions & 21 deletions cli/src/backends/aws.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,12 @@ import {
streamLabeled,
} from "../util.ts";
import { doctorCommon } from "./doctor.ts";
import { awsObjectStoreBucket, declaredVariables, terraformVarsDrift } from "../terraform.ts";
import {
assertTerraformScaffoldSupportsConfig,
awsObjectStoreBucket,
declaredVariables,
terraformVarsDrift,
} from "../terraform.ts";
import {
currentDeploymentLayerState,
deploymentLayerBody,
Expand Down Expand Up @@ -94,6 +99,7 @@ function awsTopology(
configDir: string,
): { aws: AwsConfig; workloads: string[]; plugins: ResolvedPlugin[] } {
const aws = requireAws(config);
assertTerraformScaffoldSupportsConfig(config, configDir);
const discovered = discoverPlugins(configDir, config);
if (discovered.errors.length) throw new CliError(discovered.errors.join("\n"));
const workloads = [...runnableServices(config.services), ...discovered.plugins.map((plugin) => plugin.name)];
Expand Down Expand Up @@ -286,7 +292,9 @@ function workloadEnvironment(config: QmConfig, workload: string): Record<string,
const plugin = config.plugins.find((entry) => entry.name === workload);
return Object.fromEntries(
Object.entries({
CORE_API_URL: `http://core.${requireAws(config).networking.cloudMapNamespace}:8080`,
...(plugin?.coreAccess === false
? {}
: { CORE_API_URL: `http://core.${requireAws(config).networking.cloudMapNamespace}:8080` }),
...orgEnv(workload, config.orgId, config.publicUrl, config.services.includes("portal")),
...plugin?.env,
PORT: "8080",
Expand All @@ -298,7 +306,12 @@ function workloadSecrets(config: QmConfig, workload: string, available?: Record<
const secrets = secretsForService(config, workload).filter(
(secret) => secret.required || Boolean(available?.[secret.name]),
);
if (!isServiceName(workload) && !secrets.some((secret) => secret.name === "CORE_SIGNING_SECRET")) {
const plugin = config.plugins.find((entry) => entry.name === workload);
if (
!isServiceName(workload) &&
plugin?.coreAccess !== false &&
!secrets.some((secret) => secret.name === "CORE_SIGNING_SECRET")
) {
const signing = computedSecrets(config).find((secret) => secret.name === "CORE_SIGNING_SECRET");
if (signing) return [...secrets, signing];
}
Expand All @@ -323,9 +336,10 @@ export function renderTaskDefinition(
if (service === "core" && usesFlySandboxes(config)) resolveAwsSandboxPin(config, () => undefined);
const internalPort = isServiceName(service) ? serviceDef(service).docker.internalPort : 8080;
const executionRoleArn = spec.executionRoleArn ?? `arn:aws:iam::${aws.accountId}:role/${aws.cluster}-task-execution`;
const taskRoleArn =
spec.taskRoleArn ??
`arn:aws:iam::${aws.accountId}:role/${aws.cluster}-${service === "core" ? "core-task" : "task"}`;
const managedTaskRole = spec.assumeRoleArns?.length
? `${aws.cluster}-${service}-task`
: `${aws.cluster}-${service === "core" ? "core-task" : "task"}`;
const taskRoleArn = spec.taskRoleArn ?? `arn:aws:iam::${aws.accountId}:role/${managedTaskRole}`;
const secrets = workloadSecrets(config, service, secretArns)
.flatMap((secret) =>
containerSecretNames(service, secret).map((name) => ({
Expand Down Expand Up @@ -764,6 +778,7 @@ interface DeploymentManifest {
sandboxImage?: string;
dbSnapshot?: string;
tasks: Record<string, string>;
counts?: Record<string, number>;
imageProvenance?: Record<string, DeploymentImageProvenance>;
layer?: { key: string; sha256: string };
}
Expand Down Expand Up @@ -1098,9 +1113,11 @@ function recordDeploymentManifest(
dbSnapshot?: string;
layer?: DeploymentManifest["layer"];
imageProvenance?: DeploymentManifest["imageProvenance"];
counts?: DeploymentManifest["counts"];
},
): DeploymentManifest {
const current = currentDeploymentManifest(aws);
const counts = release.counts ?? current?.counts;
const manifest: DeploymentManifest = {
id: release.id ?? randomUUID(),
...(current ? { previous: current.id } : {}),
Expand All @@ -1109,6 +1126,7 @@ function recordDeploymentManifest(
...(release.sandboxImage ? { sandboxImage: release.sandboxImage } : {}),
...(release.dbSnapshot ? { dbSnapshot: release.dbSnapshot } : {}),
tasks,
...(counts ? { counts } : {}),
...(release.imageProvenance ? { imageProvenance: release.imageProvenance } : {}),
...(release.layer ? { layer: release.layer } : {}),
};
Expand Down Expand Up @@ -1142,6 +1160,7 @@ function recordCarriedSandboxPin(aws: AwsConfig, image: string): DeploymentManif
if (current.sandboxImage === image) return current;
return recordDeploymentManifest(aws, current.tasks, {
sandboxImage: image,
...(current.counts ? { counts: current.counts } : {}),
...(current.imageLabel ? { imageLabel: current.imageLabel } : {}),
...(current.layer ? { layer: current.layer } : {}),
...(current.imageProvenance ? { imageProvenance: current.imageProvenance } : {}),
Expand Down Expand Up @@ -1256,9 +1275,8 @@ function trustedDeploymentBaseline(
action = "deploy --only",
): DeploymentManifest {
const aws = requireAws(config);
const allWorkloads = Object.keys(aws.services);
const current = currentDeploymentManifest(aws);
if (!current || allWorkloads.some((workload) => !current.tasks[workload])) {
if (!current || workloads.some((workload) => !current.tasks[workload])) {
throw new CliError(
"the first AWS deployment must include every workload; omit --only until a complete deployment manifest exists",
);
Expand Down Expand Up @@ -1292,6 +1310,27 @@ function trustedDeploymentBaseline(
return current;
}

function adoptMissingWorkloads(
manifest: DeploymentManifest,
snapshot: ReturnType<typeof serviceSnapshot>,
workloads: string[],
): boolean {
const missing = workloads.filter((workload) => !manifest.tasks[workload]);
const active = missing.filter((workload) => snapshot.counts[workload] !== 0);
if (active.length) {
throw new CliError(
`selected workload ${active.join(", ")} is missing from the current deployment manifest and must be scaled to zero before adoption`,
);
}
if (!missing.length) return false;
manifest.counts ??= {};
for (const workload of missing) {
manifest.tasks[workload] = snapshot.tasks[workload]!;
manifest.counts[workload] = snapshot.counts[workload]!;
}
return true;
}

async function applyServiceTargets(
config: QmConfig,
targets: Record<string, string>,
Expand Down Expand Up @@ -1587,11 +1626,12 @@ export async function awsUp(config: QmConfig, _configDir: string, opts: AwsUpOpt
const before = serviceSnapshot(config, allServices);
const selected = new Set(services);
if (allServices.some((service) => !selected.has(service))) {
trustedDeploymentBaseline(
const baseline = trustedDeploymentBaseline(
config,
before,
allServices.filter((name) => !selected.has(name)),
);
adoptMissingWorkloads(baseline, before, services);
}
const images: Record<string, string> = {};
for (const service of services) {
Expand Down Expand Up @@ -1644,6 +1684,15 @@ export async function awsUp(config: QmConfig, _configDir: string, opts: AwsUpOpt
assertAwsDeployImage(config);
before = serviceSnapshot(config, allServices);
current = currentDeploymentManifest(aws);
const selected = new Set(services);
if (allServices.some((service) => !selected.has(service))) {
current = trustedDeploymentBaseline(
config,
before,
allServices.filter((name) => !selected.has(name)),
);
if (adoptMissingWorkloads(current, before, services)) manifestTransaction(aws, current, current.id);
}
if (usesFlySandboxes(config)) {
if (services.includes("core")) {
const pin = resolveAwsSandboxPin(config, () => current);
Expand Down Expand Up @@ -1701,14 +1750,6 @@ export async function awsUp(config: QmConfig, _configDir: string, opts: AwsUpOpt
}
}
}
const selected = new Set(services);
if (allServices.some((service) => !selected.has(service))) {
trustedDeploymentBaseline(
config,
before,
allServices.filter((name) => !selected.has(name)),
);
}
dockerLogin(aws);
const images: Record<string, string> = {};
const selectedImageProvenance: Record<string, DeploymentImageProvenance> = {};
Expand Down Expand Up @@ -1766,21 +1807,27 @@ export async function awsUp(config: QmConfig, _configDir: string, opts: AwsUpOpt
);
}
const releaseTasks = { ...before.tasks, ...targets };
const releaseCounts = Object.fromEntries(
allServices.map((service) => [service, workloadDesiredCount(config, service)]),
);
const releaseImageProvenance = { ...current?.imageProvenance, ...selectedImageProvenance };
const sameTasks = current && allServices.every((service) => current!.tasks[service] === releaseTasks[service]);
const sameCounts = current && canonicalJson(current.counts ?? {}) === canonicalJson(releaseCounts);
const sameImageProvenance =
current && canonicalJson(current.imageProvenance ?? {}) === canonicalJson(releaseImageProvenance);
releaseSucceeded = true;
if (
!current ||
!sameTasks ||
!sameCounts ||
!sameImageProvenance ||
current.imageLabel !== label ||
layerChanged ||
current.sandboxImage !== sandboxPinImage
) {
recorded = recordDeploymentManifest(aws, releaseTasks, {
id: releaseId,
counts: releaseCounts,
...(sandboxPinImage ? { sandboxImage: sandboxPinImage } : {}),
imageLabel: label,
...(dbSnapshot ? { dbSnapshot } : {}),
Expand Down Expand Up @@ -1972,16 +2019,21 @@ export async function awsRollback(
layerNeedsSync = currentManifest?.layer?.sha256 !== targetManifest.layer!.sha256;
}
const targets = Object.fromEntries(services.map((service) => [service, targetManifest!.tasks[service]!]));
const targetCounts = Object.fromEntries(
services.map((service) => [service, targetManifest.counts?.[service] ?? before!.counts[service]!]),
);
const changedTargets = Object.fromEntries(
services
.filter((service) => before!.tasks[service] !== targets[service])
.filter(
(service) => before!.tasks[service] !== targets[service] || before!.counts[service] !== targetCounts[service],
)
.map((service) => [service, targets[service]!]),
);
if (Object.keys(changedTargets).length) {
await applyServiceTargets(
config,
changedTargets,
Object.fromEntries(Object.keys(changedTargets).map((service) => [service, before!.counts[service]!])),
Object.fromEntries(Object.keys(changedTargets).map((service) => [service, targetCounts[service]!])),
);
applied = true;
}
Expand All @@ -1990,12 +2042,12 @@ export async function awsRollback(
Object.fromEntries(
services.map((service) => [
service,
{ taskDefinition: targets[service]!, desiredCount: before!.counts[service]! },
{ taskDefinition: targets[service]!, desiredCount: targetCounts[service]! },
]),
),
);
if (targetLayerBody && layerOpts && layerNeedsSync) {
if (before.counts.core === 0) {
if (targetCounts.core === 0) {
note(
"deployment layer sync deferred: core is scaled to zero, so `check --live` will flag the layer until the next `qm up` applies it",
);
Expand Down Expand Up @@ -2222,6 +2274,7 @@ export async function awsSecretsPush(config: QmConfig, configDir: string, envFil
rotated = true;
if (Object.keys(changed).length) {
recordDeploymentManifest(aws, targets, {
...(baseline.counts ? { counts: baseline.counts } : {}),
...(baseline.sandboxImage ? { sandboxImage: baseline.sandboxImage } : {}),
imageLabel: baseline.imageLabel ?? aws.imageLabel,
...(baseline.layer ? { layer: baseline.layer } : {}),
Expand Down Expand Up @@ -3316,6 +3369,7 @@ export async function awsPinSandbox(
{ ...baseline.tasks, core: taskDefinition },
{
sandboxImage: image,
...(baseline.counts ? { counts: baseline.counts } : {}),
imageLabel: baseline.imageLabel ?? aws.imageLabel,
...(layer ? { layer } : {}),
...(baseline.imageProvenance ? { imageProvenance: baseline.imageProvenance } : {}),
Expand Down
7 changes: 4 additions & 3 deletions cli/src/backends/docker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -342,7 +342,8 @@ function serviceEnv(ctx: DockerCtx, service: ServiceName): Record<string, string

function secretEnvKeys(ctx: DockerCtx, service: string): Set<string> {
const keys = new Set(Object.keys(secretValues(ctx, service)));
if (ctx.signingSecret) keys.add("CORE_SIGNING_SECRET");
const plugin = ctx.config.plugins.find((entry) => entry.name === service);
if (ctx.signingSecret && plugin?.coreAccess !== false) keys.add("CORE_SIGNING_SECRET");
if (service === "core") {
keys.add("DATABASE_URL");
for (const key of ctx.sandboxSecretKeys) keys.add(key);
Expand Down Expand Up @@ -607,14 +608,14 @@ export async function dockerUp(
"no",
];
const wiring = {
CORE_API_URL: "http://core:8080",
...(p.coreAccess === false ? {} : { CORE_API_URL: "http://core:8080" }),
...orgEnv(p.name, config.orgId, config.publicUrl, config.services.includes("portal")),
PORT: "8080",
};
const env = {
...wiring,
...p.env,
...(ctx.signingSecret ? { CORE_SIGNING_SECRET: ctx.signingSecret } : {}),
...(ctx.signingSecret && p.coreAccess !== false ? { CORE_SIGNING_SECRET: ctx.signingSecret } : {}),
...secretValues(ctx, p.name),
};
const cleanup = pushEnvArgs(args, env, secretEnvKeys(ctx, p.name));
Expand Down
Loading