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
21 changes: 21 additions & 0 deletions examples/fullstack-app/api/Embfile.yml
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
description: REST API backend service

resources:
fixture.txt:
type: file
params:
path: .emb/fixture.txt
content: fixture-ok

tasks:
test:
description: Run API tests
Expand All @@ -12,3 +19,17 @@ tasks:
fail:
description: A task that will fail
script: exit 1

uses-fixture:
description: Reads a file produced by a resource dependency (bare name)
executors: [local]
dependencies: ['fixture.txt']
script: |
grep fixture-ok .emb/fixture.txt

uses-fixture-qualified:
description: Reads a file produced by a resource dependency (qualified id)
executors: [local]
dependencies: ['api:fixture.txt']
script: |
grep fixture-ok .emb/fixture.txt
37 changes: 36 additions & 1 deletion src/config/schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@
},
"QualifiedIdentifier": {
"type": "string",
"pattern": "^([a-zA-Z]+[\\w._-]+:)?[a-zA-Z]+[\\w._-]+$"
"pattern": "^([a-zA-Z]+[\\w._-]+:)?[a-zA-Z]+[\\w./_-]+$"
},
"KubernetesConfig": {
"type": "object",
Expand Down Expand Up @@ -170,6 +170,13 @@
"$ref": "#/definitions/QualifiedIdentifier"
}
},
"dependencies": {
"type": "array",
"description": "Resources that must be built before this task runs",
"items": {
"$ref": "#/definitions/QualifiedIdentifier"
}
},
"executors": {
"type": "array",
"items": {
Expand Down Expand Up @@ -439,6 +446,34 @@
}
}
}
},
{
"if": {
"properties": {
"type": { "const": "op/file" }
},
"required": ["type"]
},
"then": {
"properties": {
"params": {
"type": "object",
"additionalProperties": false,
"required": ["reference"],
"properties": {
"reference": {
"type": "string",
"pattern": "^op://",
"description": "Full 1Password secret reference, e.g. op://vault/item/file"
},
"path": {
"type": "string",
"description": "Optional destination path relative to the component. Defaults to the resource name."
}
}
}
}
}
}
]
},
Expand Down
5 changes: 4 additions & 1 deletion src/monorepo/operations/fs/CreateFileOperation.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { execa } from 'execa';
import { open, statfs, utimes, writeFile } from 'node:fs/promises';
import { mkdir, open, statfs, utimes, writeFile } from 'node:fs/promises';
import { dirname } from 'node:path';
import { Writable } from 'node:stream';
import * as z from 'zod';

Expand Down Expand Up @@ -42,6 +43,8 @@ export class CreateFileOperation extends AbstractOperation<
}
}

await mkdir(dirname(input.path), { recursive: true });

if (input.content !== undefined) {
await writeFile(input.path, input.content);
} else if (input.script) {
Expand Down
24 changes: 24 additions & 0 deletions src/monorepo/operations/tasks/RunTasksOperation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { EMBCollection, findRunOrder, TaskInfo } from '@/monorepo';
import { IOperation } from '@/operations';

import { ExecuteLocalCommandOperation } from '../index.js';
import { BuildResourcesOperation } from '../resources/BuildResourcesOperation.js';

export enum ExecutorType {
container = 'container',
Expand Down Expand Up @@ -51,6 +52,29 @@ export class RunTasksOperation implements IOperation<
onAmbiguous: params.allMatching ? 'runAll' : 'error',
});

const { resources } = monorepo;
const qualifyDep = (dep: string, component?: string) => {
if (dep.includes(':') || !component) {
return dep;
}

const qualified = `${component}:${dep}`;
return resources.some((r) => r.id === qualified) ? qualified : dep;
};

const resourceDeps = [
...new Set(
ordered.flatMap((t) =>
(t.dependencies ?? []).map((d) => qualifyDep(d, t.component)),
),
),
];
if (resourceDeps.length > 0) {
await monorepo.run(new BuildResourcesOperation(), {
resources: resourceDeps,
});
}

const hasInteractiveTasks = ordered.find((t) => t.interactive === true);
if (hasInteractiveTasks) {
monorepo.setTaskRenderer('silent');
Expand Down
14 changes: 14 additions & 0 deletions src/monorepo/plugins/OnePasswordPlugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,20 @@ export interface OnePasswordPluginConfig {
* API_KEY: ${op:Development/api-keys#secret-key}
* ```
*
* Supported reference shapes:
* - `vault/item#field` — reads a single field from the item
* - `vault/item` — reads the whole item as a record
*
* To materialize a 1Password file attachment on disk, use the `op/file`
* resource type (which writes the raw bytes via `op read --out-file`):
* ```yaml
* resources:
* fastlane/keystore.jks:
* type: op/file
* params:
* reference: op://MyVault/MyItem/keystore.jks
* ```
*
* Authentication methods:
* 1. Pre-authenticated session (interactive) - User runs `op signin` before using EMB
* 2. Service account token (CI/CD) - Via `OP_SERVICE_ACCOUNT_TOKEN` env var
Expand Down
96 changes: 96 additions & 0 deletions src/monorepo/resources/OpFileResourceBuilder.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { mkdir, statfs } from 'node:fs/promises';
import { dirname } from 'node:path';
import { Writable } from 'node:stream';
import * as z from 'zod';

import { getContext } from '@/context.js';
import { IResourceBuilder, ResourceInfo } from '@/monorepo';
import { AbstractOperation, OpInput, OpOutput } from '@/operations';
import { OnePasswordProvider } from '@/secrets/providers/OnePasswordProvider.js';

import { ResourceBuildContext, ResourceFactory } from './ResourceFactory.js';

const schema = z.object({
reference: z
.string()
.describe('Full 1Password secret reference, e.g. op://vault/item/file'),
path: z.string().describe('Absolute path where the file should be written'),
});

export class FetchOpFileOperation extends AbstractOperation<
typeof schema,
unknown
> {
constructor(protected out?: Writable) {
super(schema);
}

protected async _run(input: z.input<typeof schema>): Promise<void> {
const context = getContext();
const provider = context?.secrets?.get('op') as
| OnePasswordProvider
| undefined;

if (!provider) {
throw new Error(
"1Password plugin is not registered. Add it to your .emb.yml: plugins: [{ name: 'op' }]",
);
}

await mkdir(dirname(input.path), { recursive: true });
await provider.fetchFileAttachment(input.reference, input.path);
}
}

export class OpFileResourceBuilder implements IResourceBuilder<
OpInput<FetchOpFileOperation>,
OpOutput<FetchOpFileOperation>,
boolean
> {
constructor(
protected context: ResourceBuildContext<OpInput<FetchOpFileOperation>>,
) {}

private get relPath(): string {
return this.context.config.params?.path || this.context.config.name;
}

async getReference(): Promise<string> {
return this.context.component.relative(this.relPath);
}

async getPath() {
return this.context.component.join(this.relPath);
}

async mustBuild() {
try {
await statfs(await this.getPath());
} catch {
return true;
}
}

async build(
resource: ResourceInfo<OpInput<FetchOpFileOperation>>,
out?: Writable,
) {
if (!resource.params?.reference) {
throw new Error(
`Resource '${resource.id}' (type op/file) requires a 'reference' param, e.g. op://vault/item/file`,
);
}

const input: OpInput<FetchOpFileOperation> = {
reference: resource.params.reference,
path: await this.getPath(),
};

return {
input,
operation: new FetchOpFileOperation(out),
};
}
}

ResourceFactory.register('op/file', OpFileResourceBuilder);
1 change: 1 addition & 0 deletions src/monorepo/resources/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import './FileResourceBuilder.js';
import './OpFileResourceBuilder.js';
export * from './abstract/index.js';
export * from './ResourceFactory.js';
export * from './types.js';
84 changes: 71 additions & 13 deletions src/secrets/providers/OnePasswordProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,25 +107,18 @@ export class OnePasswordProvider extends AbstractSecretProvider<OnePasswordProvi
// Ensure we're connected before fetching (lazy initialization)
await this.connect();

// Parse path as vault/item
const slashIndex = ref.path.indexOf('/');
if (slashIndex === -1) {
throw new OnePasswordError(
`Invalid secret path '${ref.path}'. Expected format: vault/item`,
'OP_INVALID_PATH',
);
}
const segments = ref.path.split('/').filter(Boolean);

const vault = ref.path.slice(0, slashIndex);
const item = ref.path.slice(slashIndex + 1);

if (!vault || !item) {
if (segments.length !== 2) {
throw new OnePasswordError(
`Invalid secret path '${ref.path}'. Both vault and item must be specified.`,
`Invalid secret path '${ref.path}'. Expected format: vault/item. ` +
`For file attachments, use the 'op/file' resource type with an 'op://...' reference.`,
'OP_INVALID_PATH',
);
}

const [vault, item] = segments;

try {
const args = ['item', 'get', item, '--vault', vault, '--format', 'json'];
if (this.config.account) {
Expand Down Expand Up @@ -187,4 +180,69 @@ export class OnePasswordProvider extends AbstractSecretProvider<OnePasswordProvi
);
}
}

/**
* Fetch a 1Password file attachment and write its raw bytes to `destPath`.
* Uses `op read --force --out-file` so the CLI writes the file directly,
* bypassing stdout (which replaces non-UTF-8 bytes with U+FFFD and therefore
* corrupts binary attachments like keystores and .p8 keys).
*
* @param reference A full 1Password secret reference, e.g. `op://vault/item/file`
* @param destPath Absolute path where the attachment should be written
*/
async fetchFileAttachment(
reference: string,
destPath: string,
): Promise<void> {
await this.connect();

if (!reference.startsWith('op://')) {
throw new OnePasswordError(
`Invalid 1Password reference '${reference}'. Expected format: op://vault/item/file`,
'OP_INVALID_REFERENCE',
);
}

const args = ['read', '--force', '--out-file', destPath, reference];
if (this.config.account) {
args.push('--account', this.config.account);
}

try {
await this.execOp(args);
} catch (error) {
const err = error as NodeJS.ErrnoException & { stderr?: string };
const stderr = err.stderr || err.message || '';

if (stderr.includes("isn't a vault")) {
throw new OnePasswordError(
`Vault in reference '${reference}' not found in 1Password`,
'OP_VAULT_NOT_FOUND',
);
}

if (stderr.includes("isn't an item")) {
throw new OnePasswordError(
`Item in reference '${reference}' not found`,
'OP_ITEM_NOT_FOUND',
);
}

if (
stderr.includes('not signed in') ||
stderr.includes('not currently signed in') ||
stderr.includes('session expired')
) {
throw new OnePasswordError(
"Not signed in to 1Password. Run 'op signin' or set OP_SERVICE_ACCOUNT_TOKEN",
'OP_NOT_AUTHENTICATED',
);
}

throw new OnePasswordError(
`Failed to read '${reference}': ${stderr}`,
'OP_FILE_FETCH_ERROR',
);
}
}
}
Loading
Loading